Вы написали список дел, закрыли вкладку — и всё пропало. Для пользователя это катастрофа: он потратил десять минут, а сайт всё забыл. В настоящих приложениях корзина интернет-магазина, тёмная тема и недочитанный урок переживают перезагрузку страницы. Сегодня вы научитесь хранить все данные приложения в одном месте, рисовать экран по этим данным и сохранять их в браузере.
Описывать состояние приложения одним объектом и отличать его от «производных» данных
Писать функцию render(state), которая рисует экран только по состоянию
Менять состояние через небольшие функции-действия: добавить, отметить, удалить
Сохранять и читать данные через localStorage вместе с JSON.stringify и JSON.parse
Не падать, когда данных нет, они повреждены или устарели
Просить ИИ предложить структуру состояния и сравнивать её со своей
Как работать с этой страницей
Код на тёмном фоне с кнопкой ▶ Запустить — живой: меняйте и запускайте. Блоки «Практика» проверяются автоматически по тексту в консоли. В нескольких задачах есть мини-страница с предпросмотром: слева код (вкладки index.html, style.css, script.js), справа результат. Подсказки спрятаны — сначала попробуйте сами.
Важно про localStorage. Код на этой странице работает в изолированной «песочнице», где настоящего localStorage нет. Мы подробно разберём это в разделе 04 и дадим вам точную заглушку с тем же API. На вашем компьютере (в домашнем задании) вы будете использовать настоящий.
01 Что такое состояние приложения
Состояние (state) — это все данные, которые описывают приложение прямо сейчас и могут измениться: список задач, какие из них выполнены, выбранный фильтр, тема оформления, товары в корзине. Если сделать «снимок» состояния, по нему можно полностью воссоздать то, что видит пользователь.
Аналогия из жизни
Табло на автовокзале в Душанбе. Само табло ничего не «помнит»: оно только показывает расписание, которое лежит в системе диспетчера. Отменили рейс в Худжанд — диспетчер поменял запись, и табло перерисовалось. Расписание в системе — это состояние. Табло — экран. Если бы кто-то вручную исправил надпись на табло, не тронув систему, при следующем обновлении правка бы исчезла, а в зале начался бы хаос.
Как делают новички: данные «разбросаны»
Первое, что приходит в голову — завести отдельную переменную на каждую мелочь, а частью данных вообще считать то, что написано на странице. Такой код быстро превращается в кашу:
Данные в разных местах · Ctrl + Enter
let ownerName = "Мадина";
let balance = 150;
let operations = 2;
let lastOperation = "пополнение";
functionshowWallet() {
console.log(`${ownerName}: ${balance} сомони, операций: ${operations}, последняя — ${lastOperation}`);
}
showWallet();
Пока переменных четыре, это терпимо. Но когда их двадцать, нельзя ответить на простые вопросы: что именно нужно сохранить? Что передать функции? Что очистить при выходе пользователя?
Как делают в настоящих проектах: один объект
Все изменяемые данные приложения складывают в один объект — обычно его называют state.
Всё состояние в одном объекте · Ctrl + Enter
const state = {
ownerName: "Мадина",
balance: 150,
history: ["пополнение", "покупка"],
};
functionshowWallet(state) {
const last = state.history[state.history.length - 1];
console.log(`${state.ownerName}: ${state.balance} сомони, последняя операция — ${last}`);
}
showWallet(state);
console.log(JSON.stringify(state)); // всё приложение — одним взглядом
Выгоды сразу видны: функции получают state и больше ничего не ищут; «что сохранять» — это просто state; отладка проще — достаточно вывести один объект и увидеть всё приложение.
Действиепользователь нажал «Добавить»
→
Состояниеstate.todos стал на одну задачу больше
→
Экранrender(state) перерисовал список
Главный цикл любого интерфейса: действие меняет состояние, экран рисуется по состоянию. Весь урок — про этот цикл.
Что класть в состояние, а что — нет
В состоянии хранят исходные факты, которые нельзя вывести из других данных. Всё, что можно посчитать из состояния, в нём хранить не нужно — это производные данные.
Лишнее в состоянии
Сумма и счётчик «живут отдельно» от списка — легко забыть обновить один из них, и они разойдутся.
JavaScript
const state = {
items: [{ title: "Самбуса", price: 5, qty: 3 }],
total: 15, // можно посчитать
itemsCount: 1, // можно посчитать
};
Только факты
В состоянии — товары. Сумму считает функция, и она всегда актуальна.
Список задач, выбранный фильтр, тема, товары в корзине, имя пользователя, номер текущего шага.
Не в состояние
Сумма корзины, количество невыполненных задач, готовые HTML-строки — всё, что можно вычислить из состояния.
Решаем осознанно
Временные вещи вроде набираемого в поле текста. Их можно держать в состоянии, а можно не сохранять — об этом ещё поговорим в разделе про ИИ.
Частая ошибка
Копирование числа из состояния в локальную переменную и изменение копии. Числа и строки копируются по значению: изменили копию — состояние осталось прежним.
Состояние не изменилось · Ctrl + Enter
const state = { visitors: 10 };
let visitors = state.visitors; // скопировали число
visitors = visitors + 1; // изменили копию
console.log(visitors); // → 11
console.log(state.visitors); // → 10 — в состоянии ничего не поменялось
Объект пишется в фигурных скобках: { ключ: значение, ключ: значение }. Значения можно взять из переменных выше.
Подсказка 2
Если ключ и переменная называются одинаково, можно записать коротко: { owner, balance, currency }.
ЗАДАЧА 1.2
Корзина: считаем, а не храним
лёгкая
Напишите функцию getTotal(state), которая вычисляет сумму корзины по state.items (цена × количество). Сумму в состояние класть не нужно: покупатель меняет количество, а функция всегда считает заново.
Заведите переменную sum = 0, пройдите по state.items циклом и прибавляйте item.price * item.qty.
Подсказка 2
Или короче: state.items.reduce((sum, item) => sum + item.price * item.qty, 0). Не забудьте return.
ЗАДАЧА 1.3
Найди баг: счётчик, который не считает
средняя
Администратор нажимает «+1» на входе в класс, но число посетителей не растёт. Найдите причину и исправьте функцию addVisitor: она должна менять именно состояние.
Прочитайте пример выше про «копию по значению». Что именно увеличивает функция — число в состоянии или свою переменную?
Подсказка 2
Меняйте поле напрямую: state.visitors = state.visitors + 1; (или state.visitors++).
02 Рендер от состояния: render(state)
Следующая идея — самая важная в уроке. Экран не живёт своей жизнью: он каждый раз рисуется заново по состоянию. Для этого пишут функцию render. Она получает состояние и показывает его. Больше она ничего не делает: ничего не меняет и ничего не решает.
Чтобы пока не отвлекаться на HTML, «экраном» в первом примере будет консоль:
Теперь то же самое на настоящей странице. Обратите внимание на структуру скрипта: сверху состояние, потом render, потом действие и обработчик клика. Нажмите «Добавить блюдо» и посмотрите, как меняется список:
ul {padding-left: 20px; }
li {margin: 4px0; }
#info {color: #6b7280; }
const menu = document.querySelector("#menu");
const info = document.querySelector("#info");
const addBtn = document.querySelector("#add");
// 1. Состояниеconst state = {
dishes: ["Оши палав", "Самбуса"],
};
const NEXT = ["Курутоб", "Шакароб", "Манту", "Чакка"];
// 2. Рендер: рисует экран по состояниюfunctionrender() {
menu.textContent = ""; // очистили список
state.dishes.forEach((name) => {
const li = document.createElement("li");
li.textContent = name;
menu.append(li);
});
info.textContent = `Блюд в меню: ${state.dishes.length}`;
}
// 3. Действие: меняет состояние и просит перерисовать
addBtn.addEventListener("click", () => {
const next = NEXT[state.dishes.length - 2];
if (next) state.dishes.push(next);
render();
});
render();
console.log("блюд в состоянии:", state.dishes.length);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Правила хорошего render
Читает только состояние. Данные для экрана берём из state, а не из DOM. Не list.children.length, а state.todos.length.
Ничего не меняет. Внутри render нет push, state.x = … и других изменений данных.
Рисует всё целиком. Очистили контейнер и построили заново. Для списков из десятков элементов это мгновенно, а ошибок меньше: не нужно думать, «что именно поменялось».
Вызывается после каждого действия. Изменили состояние — вызвали render(). Забыли — экран «врёт».
Умеет рисовать пустое состояние. Если задач нет, показываем «Список пуст», а не белую пустоту.
Аналогия из жизни
Повар в чайхоне собирает заказ по чеку, а не по памяти. Изменился заказ — посмотрел в чек и приготовил заново. Чек — это состояние. Блюдо на столе — экран. Спорить с чеком бессмысленно: если в чеке «без лука», лука не будет.
Два источника правды
Число задач хранится и в массиве, и «на странице». Забыли обновить одно из них — они расходятся.
JavaScript
// ❌ данные берём из DOMconst count = list.children.length;
if (count >= 5) {
alert("Слишком много задач");
}
Один источник правды
Правда — в state. DOM — просто его отражение.
JavaScript
// ✓ данные берём из состоянияif (state.todos.length >= 5) {
alert("Слишком много задач");
}
Частая ошибка
Менять данные и тут же «подправлять» экран вручную (li.textContent = …, li.remove()) в каждом обработчике. Пока элемент один — работает. Когда появятся фильтры, счётчики и сохранение, вы будете забывать обновлять то одно, то другое. Меняем state, вызываем render() — и всё.
Напишите render с пустым состоянием: если задач нет, выведите Список пуст — добавьте первую задачу и больше ничего. Формат непустого списка прежний ([ ] и [x]).
Список пуст — добавьте первую задачу
[ ] Выучить localStorage
Подсказка 1
Проверьте state.todos.length === 0 в самом начале функции.
Подсказка 2
Внутри if выведите сообщение и сделайте return — тогда остальной код не выполнится (ранний выход).
ЗАДАЧА 2.3
Найди баг: забыли перерисовать
средняя
После добавления задач экран остаётся старым. Исправьте код так, чтобы render вызывался после каждого действия — но писать вызов нужно один раз, внутри addTodo, а не после каждого вызова снаружи.
Состояние поменялось, а экран нет. Кто должен сказать экрану «перерисуйся»?
Подсказка 2
Добавьте render(state); последней строкой в тело addTodo. Тогда любое добавление автоматически обновит экран.
ЗАДАЧА 2.4
Фильтр и счётчик
сложная
В состоянии появилось поле filter: "all", "active" (только невыполненные) или "done" (только выполненные). Напишите render: первой строкой Фильтр: …, затем подходящие задачи в формате [x]/[ ], и последней строкой Осталось: N — число невыполненных задач во всём списке, а не только среди видимых.
Сначала получите список для показа: через filter оставьте задачи, подходящие под state.filter. Для "all" фильтровать не нужно.
Подсказка 2
Счётчик — отдельное вычисление по state.todos: state.todos.filter((t) => !t.done).length. Видимый список на него не влияет.
03 Действия: как правильно менять состояние
Состояние меняют небольшие функции-действия (actions): addTodo, toggleTodo, removeTodo. Обработчики событий не лезут в данные сами — они только вызывают нужное действие. Каждое действие делает три вещи: меняет состояние → вызывает render() (а в следующих разделах — ещё и сохраняет).
Обработчик → действие → рендер. Данные меняются только в действиях.
Три приёма для массивов в состоянии
Все изменения списка сводятся к трём операциям. Условимся менять массив не «на месте», а присваивать новый — так проще отследить, что именно поменялось, и именно этот стиль потребуется вам дальше в React.
Касса в магазине на Зелёном базаре. Покупатель не лезет в кассу руками — он говорит продавцу: «добавьте», «уберите», «отметьте». Продавец (действие) вносит запись в журнал (состояние) и показывает итог на дисплее (рендер). Одни и те же понятные операции — каждый раз. Никто не правит дисплей карандашом.
Откуда брать id
У каждой задачи должен быть уникальный id: по нему мы находим её для отметки или удаления. Соблазн — брать state.todos.length + 1. Он ломается после первого же удаления:
Ловушка: id по длине массива · Ctrl + Enter
let todos = [{ id: 1 }, { id: 2 }, { id: 3 }];
todos = todos.filter((t) => t.id !==1); // удалили первую: осталось две
todos = [...todos, { id: todos.length + 1 }]; // новая получила id = 3
console.log(todos.map((t) => t.id).join(",")); // → 2,3,3 — два одинаковых id!
Поэтому в состоянии хранят отдельный счётчик nextId: он только растёт и никогда не выдаёт один номер дважды.
Действия в настоящей странице
Вспомните делегирование из урока 9: один обработчик на списке, а нужный элемент находим через event.target.closest(...). Нужную задачу определяем по data-id:
Связка: клик → id → действие
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) return; // клик мимо задачtoggleTodo(Number(li.dataset.id)); // data-id — строка, переводим в число
});
Обратите внимание на Number(...): значения из dataset всегда строки, а id в состоянии — число. Без перевода t.id === id никогда не сработает: 1 === "1" — это false.
Частая ошибка
Изменить состояние и сохранить результат в отдельной переменной, забыв присвоить обратно в state. Методы map и filterне меняют исходный массив — они возвращают новый.
Результат map потерян · Ctrl + Enter
const state = { todos: [{ id: 1, done: false }] };
state.todos.map((t) => ({ ...t, done: true })); // новый массив никуда не записали
console.log(state.todos[0].done); // → false — ничего не изменилось
Сначала соберите объект задачи, взяв id из state.nextId. Порядок полей: id, text, done.
Подсказка 2
Три строки: создать todo; state.todos = [...state.todos, todo];; state.nextId = state.nextId + 1;.
ЗАДАЧА 3.2
Отметить выполненной
средняя
Напишите toggleTodo(state, id) через map: у задачи с нужным id значение done меняется на противоположное, остальные задачи остаются как были. Функция marks уже написана — она показывает, какие задачи выполнены.
Напишите removeTodo(state, id) через filter. Функция addTodo уже готова и берёт id из nextId. Проверьте: после удаления и добавления новой задачи все id должны остаться уникальными.
filter оставляет элементы, для которых функция вернула true. Какие задачи надо оставить при удалении?
Подсказка 2
state.todos = state.todos.filter((t) => t.id !== id); — оставляем все, кроме задачи с этим id.
ЗАДАЧА 3.4
Корзина: изменить количество
сложная
Напишите changeQty(state, id, delta): прибавляет delta (может быть отрицательным) к количеству товара. Если после этого количество стало 0 или меньше, товар удаляется из корзины. Функция summary уже готова.
Допишите счётчик посетителей: render показывает число из state.visitors в элементе #count; кнопка «+1» увеличивает его, а кнопка «−1» уменьшает, но не ниже нуля. Менять нужно состояние, а потом вызывать render(). Нижние строки скрипта нажимают кнопки сами и выводят проверку — не удаляйте их.
const countEl = document.querySelector("#count");
const plusBtn = document.querySelector("#plus");
const minusBtn = document.querySelector("#minus");
const state = { visitors: 0 };
functionrender() {
// TODO: показать state.visitors в countEl
}
plusBtn.addEventListener("click", () => {
// TODO: увеличить state.visitors и вызвать render()
});
minusBtn.addEventListener("click", () => {
// TODO: уменьшить, но не ниже 0, и вызвать render()
});
render();
// ---- автопроверка: не меняйте ----for (let i = 0; i < 3; i++) plusBtn.click();
console.log("после +1:", state.visitors, countEl.textContent);
for (let i = 0; i < 5; i++) minusBtn.click();
console.log("после −1:", state.visitors, countEl.textContent);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
после +1: 3 3
после −1: 0 0
Подсказка 1
В render достаточно одной строки: countEl.textContent = state.visitors;. Остальное — в обработчиках, и в каждом в конце вызывается render().
Подсказка 2
Не ниже нуля: state.visitors = Math.max(0, state.visitors - 1); или проверка if (state.visitors > 0).
04 localStorage: память браузера
Сейчас всё состояние живёт в переменной. Обновили страницу — переменные создаются заново, и состояние обнуляется. Чтобы данные пережили перезагрузку, их нужно положить туда, где они не пропадают вместе со страницей. Для этого у браузера есть localStorage — небольшое хранилище «ключ → строка», привязанное к сайту.
Аналогия из жизни
Камера хранения на вокзале. Вы сдаёте сумку и получаете номерок. Уходите, гуляете по городу, возвращаетесь на следующий день — сумка на месте. Номерок — это ключ, сумка — значение. Но в эту камеру можно положить только запакованные вещи: localStorage принимает только строки. Всё остальное придётся упаковать — об этом ниже.
Что нужно знать о localStorage
Хранит пары «ключ — строка». Ключи тоже строки.
Данные не исчезают при перезагрузке страницы, закрытии вкладки и даже перезапуске браузера.
У каждого сайта своё хранилище: makefirststep.tj не видит данные example.com.
Объём — около 5 МБ на сайт: для списка дел или настроек этого с запасом.
Пользователь может очистить его в настройках или вручную изменить в DevTools. Данным из него доверять нельзя так же, как и вводу в форму.
Не храните там пароли и секреты: любой скрипт на странице может их прочитать.
Методы
Метод
Что делает
Пример
setItem(key, value)
Записывает значение (строку) по ключу. Старое значение заменяет.
localStorage.setItem("theme", "dark")
getItem(key)
Читает значение. Если ключа нет — возвращает null.
localStorage.getItem("theme")
removeItem(key)
Удаляет одну запись.
localStorage.removeItem("theme")
clear()
Удаляет все записи этого сайта.
localStorage.clear()
Важно: в песочнице этого курса настоящего localStorage нет
Весь код на этой странице выполняется в изолированном окне-«песочнице» — так мы защищаем ваш компьютер от случайного вредного кода. У такого окна нет «своего сайта», поэтому браузер запрещает ему доступ к localStorage. Проверьте сами:
Ошибка доступа — это не ваша вина и не баг урока. Чтобы вы всё равно могли тренироваться, мы даём заглушку: объект storage с теми же методами setItem, getItem, removeItem, clear. Она хранит данные в памяти, пока работает ваш код. А чтобы «перезагрузку страницы» можно было показать и в песочнице, мы будем вызывать функцию запуска приложения несколько раз подряд в одном скрипте: хранилище при этом остаётся, а переменные приложения — нет.
Заглушка storage — тот же API, что у localStorage · Ctrl + Enter
// ── заглушка localStorage ─────────────────────────────────────// В песочнице настоящий localStorage недоступен — на вашем компьютере// используйте window.localStorage. Остальной код при этом не меняется.functioncreateStorage() {
let data = {};
return {
getItem(key) {
return Object.hasOwn(data, key) ? data[key] : null;
},
setItem(key, value) {
data[key] = String(value); // как и настоящий: всё превращается в строку
},
removeItem(key) {
delete data[key];
},
clear() {
data = {};
},
};
}
const storage = createStorage();
// ─────────────────────────────────────────────────────────────
storage.setItem("theme", "dark");
console.log(storage.getItem("theme")); // → dark
console.log(storage.getItem("language")); // → null — такого ключа нет
storage.removeItem("theme");
console.log(storage.getItem("theme")); // → null
Как перейти на настоящий localStorage
Весь код в уроке пишите через переменную storage. На вашем компьютере, в обычном HTML-файле, достаточно заменить заглушку одной строкой — всё остальное работает без изменений:
На своём компьютере
const storage = window.localStorage;
Хотите попробовать настоящий прямо сейчас? Откройте любой сайт (хоть этот), нажмите F12 → вкладка Console и введите localStorage.setItem("test", "привет"), затем localStorage.getItem("test"). Увидеть записи можно на вкладке Application (в Firefox — Storage) → Local Storage. Там же их можно редактировать и удалять.
Всё превращается в строку
Это главная ловушка. Положили число — получили строку. Положили объект — получили текст "[object Object]".
Числа переводим обратно через Number(...). А для объектов и массивов есть специальный инструмент — JSON. Он как раз и нужен, чтобы «упаковать» объект в строку и «распаковать» обратно. Ему посвящён следующий раздел.
«Перезагрузка» в песочнице
Вот как мы будем имитировать перезагрузку: функция openSite — это «открытие страницы». Каждый вызов начинает с нуля, но хранилище помнит прошлые визиты:
Счётчик визитов: каждый вызов — «новая загрузка» · Ctrl + Enter
functioncreateStorage() {
let data = {};
return {
getItem: (key) => (Object.hasOwn(data, key) ? data[key] : null),
setItem: (key, value) => { data[key] = String(value); },
removeItem: (key) => { delete data[key]; },
clear: () => { data = {}; },
};
}
const storage = createStorage();
functionopenSite() {
const saved = storage.getItem("visits"); // первый раз — nullconst visits = saved ===null ? 1 : Number(saved) + 1;
storage.setItem("visits", visits);
console.log(`Добро пожаловать! Это визит №${visits}`);
}
openSite(); // пользователь открыл сайтopenSite(); // перезагрузил страницуopenSite(); // открыл на следующий день
Частая ошибка
Забыть, что при первом запуске getItem вернёт null. Если сразу сделать Number(null) + 1, получится 1 — случайно сработает, а вот null.length или JSON.parse(...).items уже упадут. Всегда думайте: «а что, если здесь пусто?».
В задачах ниже используйте готовую заглушку storage (она записана в первых строках — менять её не нужно).
ЗАДАЧА 4.1
Записать и прочитать
лёгкая
Сохраните в хранилище ключ academy со значением MakeFirstStep. Затем прочитайте и выведите его. Потом прочитайте и выведите ключ teacher, которого нет.
Из getItem всегда приходит строка. А "90" + 5 — это склейка строк.
Подсказка 2
Переведите значение в число перед сложением: Number(score) + 5.
ЗАДАЧА 4.3
Счётчик визитов
средняя
Напишите openSite(): читает из хранилища ключ visits (если его нет — считаем, что визитов 0), увеличивает на 1, сохраняет и выводит Визит №N. После трёх «открытий» выведите, что лежит в хранилище.
Прочитайте saved = storage.getItem("visits"). Если saved === null — это первый визит, иначе Number(saved) плюс 1.
Подсказка 2
Запишите новое число обратно через storage.setItem("visits", visits) — заглушка сама превратит его в строку.
ЗАДАЧА 4.4
Тёмная тема, которая помнит выбор
средняя
Напишите toggleTheme(): читает ключ theme (если его нет — тема light), переключает на противоположную (light ↔ dark), сохраняет и выводит новую тему. В конце удалите ключ и убедитесь, что чтение даёт null.
Текущая тема: storage.getItem("theme") || "light" — если пришёл null, возьмётся "light".
Подсказка 2
Новая тема: current === "light" ? "dark" : "light". Сохраните её и выведите.
05 JSON: упаковываем состояние в строку
С JSON вы познакомились на уроке 7. Здесь он решает конкретную задачу: localStorage принимает только строки, а наше состояние — объект с массивами. Поэтому перед записью мы упаковываем объект в строку (JSON.stringify), а после чтения — распаковываем обратно (JSON.parse).
state{ todos: [...] }
stringify →
строка'{"todos":[...]}'
setItem →
localStorageключ → строка
Сохранение: объект → строка JSON → хранилище. Чтение идёт в обратную сторону: getItem → parse → объект.
Туда и обратно · Ctrl + Enter
const state = {
filter: "all",
todos: [{ id: 1, text: "Купить нон", done: false }],
};
const raw = JSON.stringify(state); // объект → строка
console.log(raw);
console.log(typeof raw); // → stringconst restored = JSON.parse(raw); // строка → объект
console.log(restored.todos[0].text); // → Купить нон
console.log(restored === state); // → false — это новая копия, а не тот же объект
Аналогия из жизни
Переезд. Диван в машину целиком не поместится, поэтому его разбирают, пакуют в коробки и везут. На новой квартире собирают обратно по инструкции. stringify — «разобрать и упаковать», parse — «собрать». Диван тот же, но это уже новый собранный экземпляр, а не тот самый.
Пара функций saveState / loadState
Чтобы не писать JSON.stringify и setItem в десяти местах, прячем их в две функции. Всё приложение обращается к хранилищу только через них:
Шаблон: сохранить и прочитать состояние
const KEY = "todo-app:v1"; // ключ: имя приложения + версия форматаfunctionsaveState(state) {
storage.setItem(KEY, JSON.stringify(state));
}
functionloadState() {
const raw = storage.getItem(KEY);
return raw ===null ? null : JSON.parse(raw);
}
Ключ с префиксом todo-app: защищает от конфликта с другими приложениями на том же сайте, а :v1 пригодится, когда вы поменяете формат состояния: новая версия сможет читать новый ключ и не споткнётся о старые данные.
Что не переживёт упаковку
JSON — это только текст с простыми значениями. Не всё, что есть в объекте, можно в него превратить:
Вывод: в состоянии храните «простые» данные — строки, числа, булевы значения, массивы и объекты. Даты — как числа (Date.now()) или строки. Функции в состояние не кладут вообще: функции — это действия, они живут в коде.
Ошибки JSON
Правила формата строгие: ключи и строки только в двойных кавычках, без запятой после последнего элемента, без комментариев. Нарушили — JSON.parse бросит исключение SyntaxError, и приложение упадёт. Мы научимся этому противостоять в следующем разделе.
Этот код выдаст ошибку · Ctrl + Enter
const raw = "{'text': 'нон'}"; // одинарные кавычки — это не JSONconst data = JSON.parse(raw); // ✖ SyntaxError
console.log(data);
Совет
Чтобы быстро «разглядеть» большое состояние в консоли, вызывайте JSON.stringify(state, null, 2) — вывод будет с отступами, по две пробела на уровень. Для сохранения в localStorage отступы не нужны.
Нужный метод — JSON.stringify(settings). Результат сохраните в переменную.
Подсказка 2
Тип выводится так: console.log(typeof raw).
ЗАДАЧА 5.2
Распаковать данные
лёгкая
Из хранилища пришла строка. Распакуйте её и выведите: имя, количество тегов и тип поля age.
Можно редактировать · Ctrl + Enter
const raw = '{"name":"Фарход","age":21,"tags":["js","css","html"]}';
// ваш код
Ожидаемый результат
Фарход
3
number
Подсказка 1
const data = JSON.parse(raw); — после этого data обычный объект.
Подсказка 2
data.name, data.tags.length и typeof data.age — три вызова console.log.
ЗАДАЧА 5.3
Предскажите: что потеряется
средняя
Сначала на бумаге предскажите, что выведут четыре строки. Запустите и сверьтесь. Затем добавьте пятую строку: восстановите дату из строки через new Date(copy.deadline) и выведите её год через getUTCFullYear().
Вспомните таблицу выше: дата превращается в строку, а undefined и функции пропадают.
Подсказка 2
console.log(new Date(copy.deadline).getUTCFullYear()); — новая дата собирается из строки.
ЗАДАЧА 5.4
saveState и loadState
средняя
Напишите пару saveState(state) и loadState() с ключом todo-app:v1. Проверка: сохраните состояние, «сломайте» его в памяти и убедитесь, что loadState() вернул сохранённый вариант.
Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйтеconst storage = (() => { let d = {}; return {
getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();
const KEY = "todo-app:v1";
// function saveState(state) { ... }// function loadState() { ... }let state = {
todos: [
{ id: 1, text: "Нон", done: true },
{ id: 2, text: "Чой", done: false },
],
};
saveState(state);
state = { todos: [] }; // «перезагрузка»: в памяти всё потеряноconst restored = loadState();
console.log(restored.todos.length);
console.log(restored.todos[0].text);
Ожидаемый результат
2
Нон
Подсказка 1
В saveState — одна строка: storage.setItem(KEY, JSON.stringify(state)).
Подсказка 2
В loadState: return JSON.parse(storage.getItem(KEY)); — для этой задачи данные уже есть, пустой случай разберём в следующем разделе.
06 Пустые, битые и устаревшие данные
Код, который работает «когда всё хорошо», — это половина дела. Вторая половина — что произойдёт, когда данных нет, они повреждены или были записаны старой версией приложения. С localStorage такое случается постоянно:
Первый запуск. Пользователь пришёл впервые — в хранилище пусто, getItem вернёт null.
Мусор. Кто-то поправил запись в DevTools, запись оборвалась, другое приложение записало под тем же ключом что-то своё.
Устаревший формат. Вчера в состоянии не было поля filter, сегодня вы его добавили, а в браузере пользователя лежит вчерашний снимок.
Аналогия из жизни
Склад на курсе перед началом занятий. Администратор открывает шкаф за маркерами: а вдруг шкаф пуст (первый день), маркеры высохли (битые данные) или вместо них лежат стикеры от прошлого семестра (старый формат)? Хороший администратор не паникует: у него есть запасной комплект. Запасной комплект в коде — это состояние по умолчанию.
Пустое хранилище
Если ключа нет, getItem возвращает null. Проверьте это явно и верните состояние по умолчанию:
Первый запуск: null → состояние по умолчанию · Ctrl + Enter
Обратите внимание на { ...DEFAULT_STATE, todos: [] }: мы возвращаем копию по умолчанию, а не сам объект. Иначе все изменения попадут в «запасной комплект», и при следующем сбросе он будет уже не пустым.
Битый JSON: try / catch
Если в хранилище лежит строка, которая не является корректным JSON, JSON.parse бросает исключение. Его перехватывают через try / catch (вы уже видели это в уроке 7) и возвращаются к состоянию по умолчанию:
Строка может быть корректным JSON, но совсем не тем, что мы ждали: число 42, строка, null, объект вместо массива. Поэтому после parse проверяем форму данных:
JSON.parse(null) не падает, а возвращает null — потому что null сначала превращается в строку "null", а это корректный JSON. В итоге JSON.parse(storage.getItem(key)) при пустом хранилище «работает», но возвращает null, и уже следующая строка state.todos падает с TypeError. Поэтому проверка raw === null нужна всегда.
Состояние устарело: подмешиваем значения по умолчанию
Когда в состоянии появляются новые поля, старые снимки их не содержат. Решение — начать с состояния по умолчанию и «наложить» поверх него то, что нашли в хранилище:
Слияние: старые данные + новые поля · Ctrl + Enter
const DEFAULT_STATE = { filter: "all", theme: "light", todos: [] };
// вчерашний снимок: полей filter и theme ещё не былоconst saved = { todos: [{ id: 1, text: "Нон", done: false }] };
const state = { ...DEFAULT_STATE, ...saved };
console.log(JSON.stringify(state));
// → поля filter и theme взяты по умолчанию, todos — из сохранённого
Всё вместе: надёжный loadState
Достали строку через getItem. Если null — вернули состояние по умолчанию.
Распаковали внутри try / catch. Ошибка — вернули состояние по умолчанию.
Проверили форму: это вообще объект (а не число, строка, null, массив)? Нет — состояние по умолчанию.
Подмешали значения по умолчанию для недостающих полей и проверили важные поля: например, todos обязан быть массивом.
Шаблон надёжной загрузки
functionloadState() {
const raw = storage.getItem(KEY);
if (raw ===null) returncreateDefaultState(); // 1. пустоlet saved;
try {
saved = JSON.parse(raw); // 2. битый JSON
} catch {
returncreateDefaultState();
}
if (typeof saved !=="object"|| saved ===null|| Array.isArray(saved)) {
returncreateDefaultState(); // 3. это не объект состояния
}
const state = { ...createDefaultState(), ...saved }; // 4. добавили недостающееif (!Array.isArray(state.todos)) state.todos = []; // и починили важное полеreturn state;
}
Выглядит громоздко, но написать это нужно один раз. Зато приложение не упадёт, что бы ни лежало в хранилище. Эту функцию вы возьмёте в свой проект.
Сначала прочитайте raw = storage.getItem("todos") и проверьте, не null ли он.
Подсказка 2
if (raw === null) return []; и затем return JSON.parse(raw);
ЗАДАЧА 6.2
Повреждённые данные
средняя
В хранилище кто-то записал мусор. Дополните loadTodos(): если JSON.parse упал, выведите Данные повреждены — начинаем с пустого списка и верните пустой массив.
Оберните только строку с JSON.parse в try { ... } catch { ... }.
Подсказка 2
В try — return JSON.parse(raw);, в catch — console.log(...) с сообщением и return [];.
ЗАДАЧА 6.3
Валидный JSON, но не то
средняя
Теперь в хранилище лежит корректный JSON, но не массив. Допишите проверку формы: loadTodos() должна возвращать только массив — во всех остальных случаях пустой массив.
Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйтеconst storage = (() => { let d = {}; return {
getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();
functionloadTodos() {
const raw = storage.getItem("todos");
if (raw ===null) return [];
try {
const data = JSON.parse(raw);
// ваш код: проверьте, что data — массивreturn data;
} catch {
return [];
}
}
for (const raw of ['{"a":1}', "42", '["Нон"]', "null"]) {
storage.setItem("todos", raw);
console.log(loadTodos().length);
}
Ожидаемый результат
0
0
1
0
Подсказка 1
Для проверки «это массив?» есть готовая функция Array.isArray(data). Обычная проверка typeof тут не поможет: массив тоже «объект».
Подсказка 2
return Array.isArray(data) ? data : [];
ЗАДАЧА 6.4
Надёжный loadState
сложная
Соберите полную функцию loadState() по шаблону из урока. Состояние по умолчанию: { filter: "all", theme: "light", todos: [] }. Правила: пусто, битый JSON или не объект (число, строка, null, массив) — вернуть состояние по умолчанию. Иначе подмешать недостающие поля, а если todos оказался не массивом — заменить его на [] (остальные сохранённые поля, например filter, остаются).
Идите по шагам шаблона: null → try / catch → проверка формы → слияние. Каждый «плохой» случай заканчивается return createDefaultState().
Подсказка 2
Слияние: const state = { ...createDefaultState(), ...saved };. После него проверьте Array.isArray(state.todos) — если нет, поставьте state.todos = [];. Для случая «не объект» проверьте typeof saved !== "object" || saved === null сразу после parse.
07 ИИ-блок: просим предложить структуру состояния
Самый важный момент в любом приложении — какие данные вы храните и в каком виде. Ошибка здесь дорого обходится: переделывать структуру состояния, когда уже написаны render, действия и сохранение, — всё равно что перестраивать фундамент у готового дома. Поэтому именно сюда стоит привлечь ИИ — но как второго мнения, а не как автора.
Правила курса, которые здесь особенно важны
1) Сначала 15 минут сами: нарисуйте свой state на бумаге или в коде. 2) Только потом промпт. 3) Не вставляйте в проект код, который не можете объяснить строчку за строчкой. 4) Решение принимаете вы: ИИ предлагает, вы сравниваете и выбираете.
Порядок работы
Своя версия. Опишите приложение одним предложением и набросайте объект state. Отметьте, что из него вы сохраните в localStorage.
Промпт по формуле: контекст → задача → ограничения → формат ответа → проверка.
Сравнение. Выпишите отличия в таблицу: поле — у меня — у ИИ — что решил и почему.
Проверка. Для каждого спорного места спросите себя: «что будет, если здесь пусто или лежит мусор?».
Плохой и хороший промпт
Придумай как хранить данные для моего приложения
Почему плохо: ИИ не знает ни приложения, ни ваш уровень, ни где хранятся данные. Он выдаст общий совет или сразу огромную архитектуру на фреймворке, которого у вас нет.
Контекст: я новичок, пишу на чистом JavaScript без библиотек, учебный проект — трекер привычек. Пользователь может добавить привычку, отметить её выполненной сегодня, удалить, и переключать фильтр «все / не выполненные». Данные должны переживать перезагрузку страницы.
Задача: предложи структуру объекта состояния и объясни, что из него класть в localStorage.
Ограничения: только строки, числа, булевы значения, массивы и объекты (то, что переживёт JSON); не хранить то, что можно вычислить; у каждой привычки уникальный id.
Формат ответа: сначала пример объекта state как JS-код; затем список полей — по строке «поле — зачем нужно»; затем что сохраняем, а что нет.
Проверка: назови 2 случая, когда сохранённые данные могут оказаться некорректными, и как с ними справиться.
Это развёрнутый вариант шаблона из шпаргалки курса: «Предложи структуру объекта состояния для приложения [описание]. Объясни, почему такие поля и что положить в localStorage». Мы добавили контекст, ограничения и проверку — и получили ответ, который можно сравнивать.
Чек-лист: как проверять ответ ИИ
Нет ли производных данных. Если в состоянии лежит total, doneCount или готовый HTML — их можно вычислить, значит, они лишние.
Уникальные id. Откуда они берутся? Есть ли nextId, или ИИ предлагает length + 1 (мы видели, чем это кончается)?
Простые типы. Нет ли дат, функций, undefined, которые не переживут JSON?
Что сохранять. Нужно ли сохранять временное — например, текст, который пользователь набирает в поле прямо сейчас? Часто нет: после перезагрузки он не нужен.
Версия формата. Предложил ли ИИ ключ вида app:v1? Что произойдёт при смене структуры?
Пустые и битые данные. ИИ часто «забывает» про первый запуск и повреждённые данные — добавьте это сами.
Таблица сравнения
Вопрос
Моя версия
Версия ИИ
Решение и причина
Как хранить привычки?
habits: []
habits: [{ id, title, doneDates }]
Беру массив объектов с id — нужен для удаления
Где счётчик id?
нет
nextId
Добавляю nextId — защита от дубликатов
Что сохранять?
всё состояние
всё, кроме текста в поле ввода
Согласен: поле ввода не сохраняю
Если у вас совпало всё — отлично, вы мыслите так же, как опытный разработчик. Если расходится — это самое ценное место: выясните, у кого аргумент сильнее. Так ИИ учит, а не просто делает за вас.
Практика: закрепляем
Сначала своя структура, потом проверка «ответа ИИ» с ошибками.
ЗАДАЧА 7.1
Моя структура против структуры ИИ
средняя
Придумайте состояние для приложения «Расписание курса» (занятия с названием, днём недели и временем; можно отметить «посетил»; есть выбранный день для просмотра). Сначала запишите свою версию как объект state и выведите её через JSON.stringify(state, null, 2). Затем отправьте ИИ промпт по образцу выше, заполните таблицу сравнения в тетради и отметьте, что вы поменяли и почему. Эта задача проверяется преподавателем — отметьте её сами, когда закончите.
Можно редактировать · Ctrl + Enter
// 15 минут сами, без ИИconst state = {
// ваша структура
};
console.log(JSON.stringify(state, null, 2));
Подсказка 1
Подумайте: что здесь «исходные факты» (занятия, отметки, выбранный день), а что можно вычислить (сколько занятий посещено, список занятий на выбранный день)?
Подсказка 2
У каждого занятия нужен id, а в корне состояния — nextId. День недели проще хранить строкой или числом 1–7, а не датой.
ЗАДАЧА 7.2
Проверьте «ответ ИИ»: три ошибки
сложная
ИИ написал сохранение списка привычек, но в коде три ошибки — все из чек-листа. Найдите их и исправьте так, чтобы вывод был таким, как ниже. Менять можно функции и состояние; три нижних блока вывода не трогайте.
Запускайте после каждой правки и читайте ошибку. Первая проблема видна по строке с id (повторы!), вторая — по тому, что именно записывается в хранилище, третья — по тому, что вернёт load() при пустом хранилище.
Подсказка 2
1) Берите id из habits.nextId и увеличивайте его. 2) Перед записью — JSON.stringify(habits). 3) В load при null верните { nextId: 1, items: [] } вместо JSON.parse(null).
08 Практика на уроке: список дел, который помнит всё
Собираем всё вместе: список дел с состоянием, рендером, действиями и сохранением. Делаем в три этапа, и каждый заканчивается проверкой в консоли. Сверху над кодом — вкладки index.html, style.css, script.js. Решаем в script.js: ищите комментарии TODO.
Этап 1состояние + render + действия
→
Этап 2saveState / loadState, «перезагрузка»
→
Этап 3битые данные + удаление
Каждый этап работает сам по себе. Не переходите к следующему, пока не пройдена проверка.
Про «перезагрузку» в песочнице
Настоящую перезагрузку страницы здесь не сделать: она сотрёт и заглушку storage. Поэтому нижние строки скрипта имитируют её сами: очищают экран, забывают переменную state и снова вызывают boot(). Выжить после этого может только то, что лежит в storage — как и в настоящем браузере. Автопроверку внизу трогать не нужно.
Этап 1. Состояние, render и действия
Сначала никакого сохранения: одно состояние, одна функция render, действия addTodo и toggleTodo и два обработчика. Форма добавляет задачу, клик по задаче переключает «сделано» (делегирование из урока 9).
Практика: этап 1
ЗАДАЧА 8.1
Этап 1: список дел из состояния
средняя
Допишите всё, что помечено TODO: счётчик «Осталось: N» в render, действия addTodo и toggleTodo, обработчики формы и списка. Пустой текст (в том числе из одних пробелов) добавляться не должен.
Список дел, этап 1 · Ctrl + Enter
<formid="form"><inputid="text"placeholder="Что нужно сделать?"autocomplete="off"><button>Добавить</button></form><ulid="list"></ul><pid="counter"></p>
const form = document.querySelector("#form");
const input = document.querySelector("#text");
const list = document.querySelector("#list");
const counter = document.querySelector("#counter");
// 1. Состояние — единственный источник правдыconst state = { nextId: 1, todos: [] };
// 2. Рендер: рисует экран по состояниюfunctionrender() {
list.textContent = "";
state.todos.forEach((todo) => {
const li = document.createElement("li");
li.dataset.id = todo.id;
li.className = todo.done ? "done" : "";
li.textContent = todo.text;
list.append(li);
});
// TODO: записать в counter текст «Осталось: N», где N — число невыполненных задач
}
// 3. ДействияfunctionaddTodo(text) {
// TODO: добавить { id: state.nextId, text, done: false } в state.todos,// увеличить state.nextId и вызвать render()
}
functiontoggleTodo(id) {
// TODO: у задачи с этим id перевернуть done и вызвать render()
}
// 4. События
form.addEventListener("submit", (event) => {
// TODO: отменить отправку формы, взять input.value без пробелов по краям,// если текст не пустой — вызвать addTodo, затем очистить поле
});
list.addEventListener("click", (event) => {
// TODO: найти ближайший li (event.target.closest("li")); если нашли —// вызвать toggleTodo(Number(li.dataset.id))
});
// ---- автопроверка: не меняйте ----functionsubmitText(text) {
input.value = text;
form.dispatchEvent(newEvent("submit", { cancelable: true }));
}
render();
submitText("Купить нон");
submitText(" ");
submitText("Выучить localStorage");
list.firstElementChild.click();
console.log(state.todos.length, list.children.length);
console.log(list.firstElementChild.className, counter.textContent);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
2 2
done Осталось: 1
Подсказка 1
Счётчик: const left = state.todos.filter((t) => !t.done).length;, затем counter.textContent = "Осталось: " + left;.
Подсказка 2
Действия — как в разделе 03: addTodo делает state.todos = [...state.todos, {...}] и увеличивает nextId; toggleTodo использует map; обе заканчиваются render(). В обработчике формы не забудьте event.preventDefault().
Этап 2. Сохраняем и восстанавливаем
Теперь добавим память. Нужны три вещи: две функции saveState и loadState, вызов saveState() в конце каждого действия и функция boot() — «запуск страницы»: загрузить состояние и нарисовать экран. В настоящем проекте последней строкой скрипта стоит просто boot();.
Практика: этап 2
ЗАДАЧА 8.2
Этап 2: список переживает перезагрузку
средняя
Реализуйте saveState (запись JSON.stringify(state) в storage по ключу KEY) и loadState (если в хранилище пусто — вернуть { nextId: 1, todos: [] }, иначе распаковать). Вызовите saveState() в addTodo и toggleTodo — перед render(). Остальной код этапа 1 уже готов.
Список дел, этап 2 · Ctrl + Enter
<formid="form"><inputid="text"placeholder="Что нужно сделать?"autocomplete="off"><button>Добавить</button></form><ulid="list"></ul><pid="counter"></p>
const form = document.querySelector("#form");
const input = document.querySelector("#text");
const list = document.querySelector("#list");
const counter = document.querySelector("#counter");
// заглушка localStorage: в песочнице настоящий недоступен.// На своём компьютере замените эти строки на: const storage = window.localStorage;const storage = (() => { let d = {}; return {
getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();
const KEY = "todo-app:v1";
let state; // заполняется в boot()functionsaveState() {
// TODO: записать JSON.stringify(state) в storage по ключу KEY
}
functionloadState() {
// TODO: прочитать из storage. Пусто — вернуть { nextId: 1, todos: [] }.// Иначе — вернуть JSON.parse(...) прочитанной строки
}
functionrender() {
list.textContent = "";
state.todos.forEach((todo) => {
const li = document.createElement("li");
li.dataset.id = todo.id;
li.className = todo.done ? "done" : "";
li.textContent = todo.text;
list.append(li);
});
const left = state.todos.filter((t) => !t.done).length;
counter.textContent = "Осталось: " + left;
}
functionaddTodo(text) {
state.todos = [...state.todos, { id: state.nextId, text, done: false }];
state.nextId = state.nextId + 1;
// TODO: сохранить состояниеrender();
}
functiontoggleTodo(id) {
state.todos = state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
// TODO: сохранить состояниеrender();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (text) addTodo(text);
input.value = "";
});
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (li) toggleTodo(Number(li.dataset.id));
});
// «Запуск страницы»: загрузить состояние и нарисовать экранfunctionboot() {
state = loadState();
render();
}
// ---- автопроверка: не меняйте ----functionsubmitText(text) {
input.value = text;
form.dispatchEvent(newEvent("submit", { cancelable: true }));
}
boot();
submitText("Купить нон");
submitText("Выучить localStorage");
list.firstElementChild.click();
console.log("до перезагрузки:", list.children.length, counter.textContent);
// «перезагрузка»: экран очищен, переменные забыты — остался только storage
list.textContent = "";
counter.textContent = "";
state = null;
boot();
console.log("после:", list.children.length, list.firstElementChild.className, counter.textContent);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
до перезагрузки: 2 Осталось: 1
после: 2 done Осталось: 1
Подсказка 1
saveState — одна строка с storage.setItem. loadState — вспомните шаблон из раздела 05, только вместо null возвращается начальное состояние.
Подсказка 2
const raw = storage.getItem(KEY); return raw === null ? { nextId: 1, todos: [] } : JSON.parse(raw);. В действиях: saveState(); перед render();.
Этап 3. Защита от мусора и удаление
Приложение, которое падает от одной сломанной записи, — не приложение. Сделаем loadState надёжным (шаблон из раздела 06) и добавим кнопку «×» для удаления задачи. Кнопка находится внутри li, а клик по li уже переключает задачу — поэтому в общем обработчике сначала проверяем, куда именно нажали.
Практика: этап 3
ЗАДАЧА 8.3
Этап 3: надёжная загрузка и удаление
сложная
1) В loadState обработайте: пустое хранилище, битый JSON, и неверную форму — состояние должно быть объектом, у которого todos — массив, а nextId — число; иначе возвращайте состояние по умолчанию. 2) Допишите removeTodo(id) через filter и обработку кнопки «×» в клике по списку — нажатие на «×» должно удалять задачу, а не переключать её.
Список дел, этап 3 · Ctrl + Enter
<formid="form"><inputid="text"placeholder="Что нужно сделать?"autocomplete="off"><button>Добавить</button></form><ulid="list"></ul><pid="counter"></p>
Надёжная загрузка — это три return createDefaultState() в трёх разных случаях. Проверка формы: typeof saved === "object" && saved !== null && Array.isArray(saved.todos) && typeof saved.nextId === "number".
Подсказка 2
Клик по списку: if (event.target.matches(".remove")) removeTodo(id); else toggleTodo(id);. Внутри removeTodo — state.todos = state.todos.filter((t) => t.id !== id);, потом saveState() и render().
Что вы построили
Это скелет, на котором держатся почти все интерфейсы: состояние → действия → render → сохранение. Трекер задач и привычек из мини-проекта на уроке 12 — это тот же скелет с другими полями. Попробуйте добавить в него фильтр «все / активные / выполненные» (задача 2.4) и сохранить выбранный фильтр.
09 Домашнее задание: добавить сохранение в проект урока 9
Возьмите свой проект с урока 9 (интерактивная страница с событиями и делегированием) и научите его помнить данные. Здесь вы работаете на своём компьютере — значит, используете настоящийlocalStorage: const storage = window.localStorage;.
Что нужно сделать
В проекте одно состояние — объект state. Нигде нет данных, которые «живут» только в DOM.
Экран рисует функция render(). Обработчики событий вызывают действия, а те — render().
Есть saveState() и loadState(); ключ с префиксом проекта и версией, например myproject:v1.
Состояние сохраняется после каждого действия и восстанавливается при открытии страницы: закрыли вкладку, открыли снова — всё на месте.
loadState() переживает: пустое хранилище, битый JSON, неверную форму данных и отсутствующие новые поля.
Есть кнопка «Сбросить», которая очищает данные приложения (removeItem по вашему ключу) и возвращает начальное состояние.
В состоянии нет производных данных, функций и дат-объектов.
Как проверить свою работу
Добавьте несколько элементов, обновите страницу (F5) — всё на месте?
Откройте DevTools (F12) → Application → Local Storage. Найдите свой ключ, посмотрите, как выглядит значение.
Испортите значение вручную: удалите последнюю скобку. Обновите страницу — приложение должно запуститься с начальным состоянием, а не упасть с ошибкой в консоли.
Удалите ключ — страница должна открыться «как в первый раз».
Нажмите «Сбросить» — данные исчезли и из экрана, и из хранилища?
ИИ в домашнем задании
Правило курса: 15 минут сами. Запишите свою структуру состояния в файл NOTES.md. Потом спросите ИИ по образцу из раздела 07 и добавьте в тот же файл таблицу «моё / ИИ / решение». В коде оставляйте только то, что можете объяснить построчно. Обязательно спросите: «назови 2 случая, когда сохранённые данные окажутся некорректными» — и проверьте их руками.
Что сдавать
Папку проекта (архив или ссылка) с работающим сохранением.
Файл NOTES.md: ваша структура состояния, ключ в localStorage, что сохраняется и что нет, таблица сравнения с ИИ.
Короткую запись экрана (до 30 секунд): добавили данные → обновили страницу → данные на месте → испортили значение → приложение не упало.
Как оценивается (0–10)
Критерий
Баллы
Одно состояние state и render(), без «правды в DOM»
2
Данные сохраняются и восстанавливаются после перезагрузки
3
Корректная работа при пустом и повреждённом хранилище
NOTES.md и сравнение с ИИ: решение объяснено своими словами
2
Если страница открыта как файл
В большинстве браузеров localStorage работает и для файлов file://, но у разных файлов могут быть общие или раздельные данные — это зависит от браузера. Надёжнее запускать проект через Live Server в VS Code: тогда у него обычный адрес вида localhost:5500 и своё хранилище.
✓ Шпаргалка урока
Состояние — все изменяемые данные приложения в одном объекте state. Хранить только исходные факты; то, что можно посчитать (сумма, счётчик), считаем функцией.
render(state) рисует экран целиком и только по состоянию: ничего не меняет, не читает данные из DOM, умеет рисовать пустой список.
Цикл: событие → действие (addTodo, toggleTodo, removeTodo) → изменили state → render() → saveState().
Массивы в состоянии: добавить — [...arr, item], изменить — map, удалить — filter. Присваиваем результат обратно в state. Id берём из nextId, а не из length + 1.
localStorage:setItem(k, v), getItem(k) (нет ключа — null), removeItem(k), clear(). Хранит только строки, живёт после перезагрузки, около 5 МБ, свой для каждого сайта. Секреты не хранить.
В песочнице курса настоящий localStorage недоступен — используем заглушку storage с тем же API. На своём компьютере: const storage = window.localStorage;. Проверить настоящий можно в DevTools → Application → Local Storage.
JSON:JSON.stringify(state) перед записью, JSON.parse(raw) после чтения. Не переживают: undefined, функции, даты (станут строками).
Надёжный loadState:null → состояние по умолчанию; JSON.parse в try / catch; проверка формы; { ...defaults, ...saved } для новых полей. Ключ с префиксом и версией: app:v1.
ИИ: сначала своя структура состояния (15 минут), потом промпт по формуле и сравнение в таблице. Проверять: нет ли производных данных, откуда id, что сохранять, что с пустыми и битыми данными.