Module 15 · Forms, Storage and Small Apps
Forms and Storage, the Picture: A Page That Remembers
In this lesson
- Read the fields of a form in the
submitevent, and stop the page from leaving withpreventDefault. - Explain why every field gives back a string, and turn a number field into a number before you add it.
- Save a page's state as JSON text in storage, and read it back when the page opens again.
Nusrat runs a weekend maths club in Mirpur. She made a sign-up page with three fields: the student's name, the class and a box to tick for the Saturday batch. A parent fills it in and presses the button. The page flashes, the fields go blank, and nothing is kept anywhere.
Then a second problem appears. Nusrat types half of a long note on the page, closes the tab by mistake and opens it again. The note is gone. The page forgot everything the moment it closed.
These are the two problems of this module. A form must hand its values to your code instead of leaving the page. And a page must remember a little state between visits, without a server. Module 14 taught you to find elements and listen for events. This module builds on that, one form and one store at a time. It ends with three small apps: a note taker, a settings panel and a timer.
A form is a box of fields
A form is an element that groups fields: text boxes, number boxes, checkboxes and lists. Each field is an input, a select or a textarea. You reach a field the way Module 14 showed, with document.getElementById or querySelector, and read what the user typed from its value.
Here is Nusrat's form as a page. Press Run, type a name, pick a class and press the button.
<!doctype html>
<html>
<body>
<form id="signup">
<label>Name <input id="student" type="text"></label>
<label>Class <input id="grade" type="number" min="1" max="12"></label>
<label><input id="saturday" type="checkbox"> Saturday batch</label>
<button type="submit">Sign up</button>
</form>
<p id="result">Nobody has signed up yet.</p>
<script>
const form = document.getElementById("signup");
form.addEventListener("submit", (event) => {
event.preventDefault();
const who = document.getElementById("student").value;
const grade = document.getElementById("grade").value;
const saturday = document.getElementById("saturday").checked;
document.getElementById("result").textContent =
who + " joins class " + grade + (saturday ? ", Saturday batch" : "");
console.log({ who, grade, saturday });
});
</script>
</body>
</html>
Run in Compiler
The page shows the three fields and a line of text under them. When you press the button, the line changes to the name, the class and the batch you chose. The fields keep what you typed. The console under the page shows the same three values as one object.
A text box and a number box both give their text through value. A checkbox is different: its value is a fixed label, and whether it is ticked lives in checked, which is true or false. Lesson 02 shows every kind of field.
submit and preventDefault
A form has a built-in job. When the user presses its submit button, or presses Enter in a text box, the browser submits it. Submitting means: collect every field, send them to the address in the form's action, and load the page that comes back. That is how the web worked before JavaScript, and it still works without any script.
Before the browser does that, it fires a submit event on the form. Your listener runs first. Inside it, event.preventDefault() cancels the browser's own job, so the page stays where it is. Now your code decides what happens to the values.
Listen on the form's submit, not on the button's click. The submit event also fires when someone presses Enter in a field, and it fires only after the browser's own checks pass, such as a required field. A click listener misses the Enter key and runs even when the form is not ready.
In the reader's preview a form that is not stopped does not leave the page either, because the preview sends nothing anywhere. On a real site it would, and the fields would go blank, as they did for Nusrat. So every form handler in this module calls preventDefault() first.
Every field gives back a string
Here is a rule you met in Module 2, now with real consequences. A field's value is always a string, even in a box with type="number". The browser shows a number pad, but it hands you text. Add two such values with + and you join two strings.
A page cannot run here without the preview, so the program below stands in for two fields with plain objects. Each object has the value a number box would give.
const first = { value: "5" }; // what a type="number" box gives back
const second = { value: "2" };
console.log(first.value + second.value);
console.log(Number(first.value) + Number(second.value));
console.log(typeof first.value);
console.log(Number(""), Number(" "), Number("5kg"));
52
7
string
0 0 NaN
The first line joins the texts instead of adding the numbers. The second converts each value with Number first, and the sum is right. The last line holds three traps for later. An empty field becomes 0, a field of spaces becomes 0 too, and text that is not a number becomes NaN.
So the habit is short. Convert a field to a number the moment you read it, and decide what an empty field means before you convert it. An empty class is not class zero.
Storage keeps only strings
Now the second problem: remembering. The browser gives every site a small store called localStorage. It keeps pairs of a key and a value, and it survives closing the tab, the browser and even a restart. Its three main calls are setItem(key, value), getItem(key) and removeItem(key).
The store has one strict rule: it keeps only strings. Give setItem a number, an array or an object, and it turns the value into a string first, the way String(value) does. For an object that string is useless.
Node has no localStorage, so the program builds a stand-in with the same calls. It keeps strings in a Map and converts every value with String, just as the browser's store does.
const data = new Map();
const store = {
setItem(key, value) { data.set(key, String(value)); },
getItem(key) { return data.has(key) ? data.get(key) : null; },
};
store.setItem("count", 3);
console.log(store.getItem("count"), typeof store.getItem("count"));
store.setItem("settings", { theme: "dark" });
console.log(store.getItem("settings"));
store.setItem("settings", JSON.stringify({ theme: "dark" }));
console.log(store.getItem("settings"));
console.log(JSON.parse(store.getItem("settings")).theme);
console.log(store.getItem("missing"));
3 string
[object Object]
{"theme":"dark"}
dark
null
The number comes back as the string "3". The object comes back as [object Object], and the theme inside is lost for good. Wrapping the object in JSON.stringify stores real text, and JSON.parse turns it back into an object. A key that was never set gives null, not undefined.
That pair is the whole storage pattern. Write with JSON.stringify, read with JSON.parse. Module 8 taught JSON itself; here it becomes the bridge between your objects and a store of strings.
The preview has no storage of its own
Try the real localStorage in the reader's preview. The page below asks for it inside a try, and reports what happened.
<!doctype html>
<html>
<body>
<p id="report">Checking storage...</p>
<script>
const report = document.getElementById("report");
try {
localStorage.setItem("visits", "1");
report.textContent = "Storage works on this page.";
} catch (err) {
report.textContent = "Storage refused: " + err.name;
console.log(err.name + ": " + err.message);
}
</script>
</body>
</html>
Run in Compiler
In the preview, the page says Storage refused: SecurityError, and the console shows the browser's own message, which differs between browsers. The preview is a sealed frame with no origin of its own, and storage belongs to an origin. So localStorage, sessionStorage, indexedDB and document.cookie all throw there.
On a real site the same calls work. They also work if you save this page as a file and open it in your own browser. So the pages in this module do what careful real code does anyway. They try the real store, and if it throws, they fall back to a small stand-in in memory. The stand-in has the same getItem, setItem and removeItem, so the rest of the page never knows the difference. In the preview, the "remembered" state lasts until you press Run again.
The state, saved and read back
A small app keeps everything it knows in one object, its state: the notes, the chosen theme, the time on a timer. Saving the app means turning that object into text and storing it. Opening the app means reading the text and building a new object from it.
Watch it happen one line at a time. The arrows show which name points at which object.
Step 3 is the turning point. saved holds no arrows at all, only characters. That is what storage keeps. Step 4 builds a whole new family of objects from those characters, which is what happens every time the app opens. Step 5 shows that the two families are separate: changing one never touches the other.
Forms and storage at a glance
form.addEventListener("submit", (event) => { // runs before the browser submits
event.preventDefault(); // stay on this page
const text = input.value; // always a string
const n = Number(numberInput.value); // convert before you add
const ticked = checkbox.checked; // true or false
});
localStorage.setItem("key", JSON.stringify(state)); // write: object to text
const state = JSON.parse(localStorage.getItem("key")); // read: text to object, null if never set
Here is the whole page. It keeps a list of sign-ups in its state, saves the list after every submit, and shows the count. It tries the real store first and falls back to the stand-in.
<!doctype html>
<html>
<body>
<form id="signup">
<label>Name <input id="student" type="text" required></label>
<label>Class <input id="grade" type="number" min="1" max="12" required></label>
<button type="submit">Sign up</button>
</form>
<p id="count"></p>
<script>
function memoryStore() {
const data = new Map();
return {
getItem(key) { return data.has(key) ? data.get(key) : null; },
setItem(key, value) { data.set(key, String(value)); },
removeItem(key) { data.delete(key); },
};
}
let store;
try {
localStorage.getItem("probe");
store = localStorage;
} catch (err) {
console.log("using the memory stand-in: " + err.name);
store = memoryStore();
}
const signups = JSON.parse(store.getItem("club-signups")) || [];
const show = () => {
document.getElementById("count").textContent = signups.length + " signed up";
};
show();
document.getElementById("signup").addEventListener("submit", (event) => {
event.preventDefault();
const who = document.getElementById("student").value.trim();
const grade = Number(document.getElementById("grade").value);
signups.push({ who, grade });
store.setItem("club-signups", JSON.stringify(signups));
event.target.reset();
show();
});
</script>
</body>
</html>
The page opens with 0 signed up. Each sign-up adds one to the count and empties the form for the next parent. Leave a field empty and press the button: the browser shows its own message on that field, and the submit event never fires. On a real site the count survives a reload, because the list lives in localStorage.
Look at JSON.parse(store.getItem("club-signups")) || []. On the very first visit there is nothing saved, so getItem gives null. JSON.parse(null) gives null back, and || [] turns that into an empty list.
The page above cannot print its saved text here, so this program runs the same save and load under Node, with the stand-in. The "reload" is a second state built only from the store.
const data = new Map();
const store = {
getItem(key) { return data.has(key) ? data.get(key) : null; },
setItem(key, value) { data.set(key, String(value)); },
};
const signups = JSON.parse(store.getItem("club-signups")) || [];
signups.push({ who: "Rafi", grade: 7 });
signups.push({ who: "Mitu", grade: 9 });
store.setItem("club-signups", JSON.stringify(signups));
console.log(store.getItem("club-signups"));
const afterReload = JSON.parse(store.getItem("club-signups")) || [];
console.log(afterReload.length, afterReload[1].who, typeof afterReload[1].grade);
console.log(afterReload === signups);
[{"who":"Rafi","grade":7},{"who":"Mitu","grade":9}]
2 Mitu number
false
The first line is the exact text the store holds. The second shows the list rebuilt from that text: two sign-ups, and the class is still a number, because JSON keeps numbers as numbers. The last line is false: the reloaded list is a new array, as in the widget's step 4.
Where this is used
- Wordle on The New York Times: your streak and statistics live in the browser's
localStorage, which is why a new phone shows them at zero. - Excalidraw: the whiteboard keeps your drawing in
localStorage, so a reload brings it back without any account. - Every browser's built-in form check: a
requiredfield stops the submit and shows a message on its own, before any script runs.
Common mistakes
1. Storing an object without JSON
const data = new Map();
const store = {
setItem(key, value) { data.set(key, String(value)); },
getItem(key) { return data.get(key); },
};
const settings = { theme: "dark", size: 18 };
store.setItem("settings", settings);
console.log(store.getItem("settings"));
console.log(store.getItem("settings").theme);
[object Object]
undefined
The store turned the object into the text [object Object], and the theme and the size are gone. Reading .theme from a string gives undefined, and no error warns you. Write store.setItem("settings", JSON.stringify(settings)), and read it back with JSON.parse.
2. Adding two fields as strings
const price = { value: "120" };
const quantity = { value: "3" };
const deliveryFee = 60;
console.log(price.value * quantity.value + deliveryFee);
console.log(price.value + deliveryFee);
420
12060
The first line looks right, because * converts both strings to numbers on its own. The second joins "120" and 60 into a long string. The code works for one operator and fails for the next. Convert every field with Number as you read it, and the question never comes up.
3. Parsing a key that was never saved
const data = new Map();
const getItem = (key) => (data.has(key) ? data.get(key) : null);
const state = JSON.parse(getItem("notes-app"));
console.log(state.notes.length);
TypeError: Cannot read properties of null (reading 'notes')
On a first visit nothing is saved, so getItem gives null, and JSON.parse(null) gives null too. The next line stops with TypeError: Cannot read properties of null (reading 'notes'). Always give a default for a first visit: JSON.parse(getItem("notes-app")) || { notes: [] }.
Tanvir built a small quiz app that remembers its answers in the browser's storage. He called setItem with plain values and forgot JSON.stringify. Storage keeps only text, so every value was turned into a string the way String(value) does it. Tanvir wants to see what his app really saved.
Input. The first line holds n. Each of the next n lines holds one value written as JSON: a number, a string, true, false, null, an array or an object.
Output. One line per value: the text that getItem would give back after setItem stored that value. Print the text itself as a JSON string, with its quotes, so an empty text still shows as "".
Constraints. 1 <= n <= 1000. Each line is valid JSON of at most 200 characters, and the last line is not empty..
Sample.
6
42
"hello"
{"theme":"dark"}
[1,[2,3]]
null
[]
"42"
"hello"
"[object Object]"
"1,2,3"
"null"
""
const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Run in Compiler
Graded as setitem-strings, a free problem in this module's problem set.
Nusrat wants to see exactly what her sign-up page saves. Read the sign-ups, build a list of objects with the class as a number, and store it as JSON. Then print the stored text and the count read back from it.
Input. n, then n lines, each a name and a class.
Output. the stored JSON text on one line, then count N, where N is the length of the list parsed back from that text.
Constraints. 1 <= n <= 20. A name is 1 to 10 letters, and a class is 1 to 12.
Sample. the input below.
3
Rafi 7
Mitu 9
Sumon 7
const input = require("fs").readFileSync(0, "utf8");
const tokens = input.split(/\s+/).filter(Boolean);
let at = 0;
const next = () => tokens[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Sample output.
[{"who":"Rafi","grade":7},{"who":"Mitu","grade":9},{"who":"Sumon","grade":7}]
count 3
Run in Compiler
A fees page has a column of number fields, and Nusrat copied their values out as typed. Some are empty, some hold only spaces, and some hold text that is not a number. Add up the real numbers, and count the other two kinds separately.
Input. n on the first line, then n lines, each one field's value exactly as typed. A line may be empty.
Output. three lines: total T with the sum of the fields that hold a number, empty E with the fields that are empty or only spaces, and invalid I with the rest.
Constraints. 1 <= n <= 50. A value is at most 10 characters, and the last line is not empty.
Sample. the input below. Its third line is empty, and its fourth holds two spaces.
6
500
250
abc
1200
const input = require("fs").readFileSync(0, "utf8");
const lines = input.split("\n");
let at = 0;
const next = () => lines[at++];
const nextInt = () => Number(next());
const out = [];
// your code: read with next() and nextInt(), push every line of output to out
console.log(out.join("\n"));
Sample output.
total 1950
empty 2
invalid 1
Run in Compiler
Common doubts
Why does the preview refuse storage?
Storage belongs to an origin, the scheme, host and port of a site. The preview runs each page in a sealed frame without an origin, so no other page can read what it stores. The browser therefore refuses storage there, and the same code works on any real site.
Is localStorage the same as a cookie?
No. Both live in the browser, but a cookie travels to the server with every request, and
localStoragenever leaves the browser. Cookies are also tiny. Lesson 05 shows what a cookie carries.Does preventDefault stop the user from typing?
No. It cancels only the default action of the event it is called on. For
submit, that action is sending the form and loading a new page. Typing, clicking and the browser's own checks are untouched.What happens to localStorage when the browser's data is cleared?
It is gone, together with cookies and the rest of that site's data. Private windows also start with an empty store and throw it away at the end. So
localStorageis for things that are nice to keep, never for the only copy of something important.
Key takeaways
- Listen for
submiton the form, callevent.preventDefault(), then read the fields. - Every field's
valueis a string; a checkbox's state ischecked; convert numbers as you read them. - Storage keeps only strings: write with
JSON.stringify, read withJSON.parse, and give a default fornull. - The preview has no storage, so a page tries the real store and falls back to a stand-in with the same calls.
- An app's state is one object; saving turns it into text, and loading builds a new object from that text.
- Go deeper: Under the Hood, Origins, the Storage Event, the Main Thread and Cookies (Pro).
Next: every kind of field, the browser's own validation, FormData, both storages and dates, one by one.
End of lesson 1
Mark it done, and your progress moves with you.
Next: Every Form, Storage and Date Tool, One by One