← Front-End Developer — all chapters  ·  Kelick Tech Hub

Chapter 23 — Local Storage and State

Section 1 — What state is

State is the information a page needs to remember in order to do its job. It is not the HTML on the screen, and it is not the code that runs; it is the data that sits between them, describing the current situation. For a todo list, the state is the list of tasks, which ones are done, and which filter is selected. For a shopping basket, the state is the items in the basket and their quantities. For a theme switcher, the state is whether the page is in light or dark mode. For a form, the state is what the visitor has typed so far. In each case, if you deleted the state, the page would have nothing to show and nothing to work with, even though its code and its empty HTML would still exist. State is the page's memory of what is true right now.

That memory changes over time, and tracking how it changes is the core of front-end programming. A page starts with an initial state, perhaps an empty list or a default theme, and each visitor action changes it: adding a task, ticking a box, choosing a filter, typing a character. Each change should lead, in a predictable way, to a new display, so the visitor sees the effect of what they did. When the connection between state and display is clear, the page feels reliable: what you see is what the page knows. When that connection is tangled, the page feels buggy: a task appears ticked but counts as unticked, a filter shows one thing while the list shows another, or a change disappears when the page is reloaded. Those bugs are almost always state bugs, not display bugs, because the display was faithfully showing a state that was itself wrong or out of date.

There is a useful question to ask about any page you build: if I froze the page at this moment, what information would I need to write down to rebuild exactly this screen later? The answer to that question is the page's state, and writing it down explicitly, as a variable or an object, is the first step to managing it well. Beginners often skip that step, letting state hide in scattered places: a class on an element, a value in an input, a variable here and there, with no single place that holds the whole picture. That scattering works for a tiny page, but it collapses as the page grows, because no one, including the original author a week later, can say what the page currently knows. Naming the state, giving it a home, and changing it only through clear steps are the disciplines this chapter teaches, and they are what turn a page that happens to work into a page that can be understood, fixed, and extended.

Check: In plain words, what is a page's state?

Section 2 — State versus display

State and display are two different things, and confusing them is one of the most common sources of front-end bugs. State is the data: the list of tasks, their completion, the selected filter. Display is what the visitor sees: the HTML elements, their text, their classes, their colours. Display should be a reflection of state, like a mirror reflecting a face. If the face changes, the mirror changes, but the mirror is not the face, and changing the mirror does not change the face. In the same way, changing the HTML directly, for example by toggling a class on a list item, changes the display, but it does not change the underlying state, and the next time the page re-renders from state, the direct change will be overwritten, as if it never happened. That overwriting surprises beginners who edited the display and expected the change to stick.

Keeping state and display separate has a practical benefit: it gives you one place to look when something is wrong. If the display is wrong, you ask two questions in order. First, is the state correct? If the state is wrong, fix the state, and the display will follow when it re-renders. If the state is correct but the display is wrong, the rendering code is at fault, and you fix that instead. Without the separation, you cannot ask those questions, because there is no state to inspect apart from the display itself, and a bug could be hiding in either place. With the separation, debugging becomes a process of elimination, and the rendering function, covered in Section 6, becomes the bridge between the two worlds: it takes state in, and it produces display out, with no other job.

There is also a performance and clarity benefit. When display is derived from state, you can change the display's appearance without touching the state, for example by restyling a list, and you can change the state's structure without touching the display's styling, as long as the rendering function translates between them. The two can evolve independently, connected only by the rendering function. That independence is what lets a team, or a single developer over time, improve a page's look without risking its logic, and improve its logic without breaking its look. The rule to carry forward is simple to state and powerful to follow: state is the truth, display is a reflection of the truth, and all changes go through state first, with display updated afterwards by rendering. The rest of this chapter is largely about making that rule practical, in code, in storage, and in the habits that keep it true.

A practical way to keep the separation clear is to make rendering the only code that writes to the display, and state functions the only code that writes to state. If a handler needs to change what is shown, it should change state and call render, not write to the DOM directly, and if a rendering function needs data, it should read state, not read the DOM. That rule, applied consistently, means there is never a question about where a display change came from: it came from a state change, through rendering, and that traceability makes debugging straightforward.

Check: Why does changing the HTML directly not change the page's state?

Section 3 — A single source of truth

A single source of truth is the principle that each piece of information should live in exactly one place, and every other place that needs it should derive it from there, rather than keeping its own copy. For a todo list, the list of tasks should live in one variable, and the displayed list, the count of remaining tasks, and the filtered view should all be derived from that variable when they are needed. The alternative, keeping a copy of the list in a variable, another copy in the HTML, and a third copy in a count somewhere, guarantees that the copies will eventually disagree. One copy will be updated and another forgotten, and the page will show two different truths at once, with no way to tell which is right. A single source of truth prevents that disagreement by removing the possibility of it: there is only one copy, so there is nothing to disagree with.

The principle applies at several levels. Within a page, it means one variable for the tasks, not a variable plus the DOM plus a hidden field. Across a page's features, it means a filter setting lives in one place, and both the list rendering and the filter buttons read it from there, rather than each tracking it separately. Across page loads, it means the saved copy in local storage, covered later in this chapter, is a copy of the same state, loaded back into the same variable on the next visit, not a separate, parallel list that must be kept in sync by hand. Each level has the same idea: one home for each fact, and everything else is a view of that home. When you need to change a fact, you change it in its home, and every view updates when it next renders.

Adopting a single source of truth requires discipline, because the tempting shortcut is always to update the display directly and skip the state, or to keep a convenient second copy "just for this feature". Those shortcuts feel faster in the moment, and they are the source of most state bugs in growing pages. A useful test is to ask, for any piece of information on a page, "Where is this stored?" If the answer is a single, named place, the design is sound. If the answer is "in a few places", or "I am not sure", the design needs refactoring before it grows further. That test, applied regularly, keeps a page's state honest, and it is the reason this chapter returns to the idea repeatedly: a page with one truth is a page you can reason about, fix, and extend, while a page with several truths is a page you must guess about, and guessing is not engineering.

Check: What goes wrong when the same information is stored in two places?

Section 4 — Variables as state

In plain JavaScript, state most often lives in variables, and the simplest state is a single variable holding a single value. A theme setting might be a string variable holding "light" or "dark". A counter might be a number variable holding its current count. A search term might be a string variable holding what the visitor typed. Those variables are state, and changing them, by assignment, changes the page's situation, though the display will not update until your code renders the new value. That gap, between changing a variable and updating the display, is why the render step matters, and it is also why state variables should be changed deliberately, through functions, rather than scattered assignments throughout the code, a point Section 17 develops.

More complex state needs more complex variables. A list of tasks is naturally an array, where each element is an object describing one task, with fields for its text, its completion, and an identifier. A form's state might be an object with a field per input, such as name, email, and message. A dashboard's state might be an object holding several lists, a filter, a page number, and a loading flag, all together. Choosing the shape of the state variable is a design decision, and it should mirror the structure of the information itself: a list for a list, an object for a set of named fields, and a nested structure when the information itself is nested. A well-chosen shape makes the code that reads and changes it straightforward, while a poorly chosen shape, such as a single string encoding several facts, makes every operation a parsing exercise.

Variables as state have limits that the rest of this chapter addresses. A variable lives only as long as the page is open; closing the page or refreshing it destroys the variable and its contents, which is why persistence, through local storage, is needed for state that should survive. A variable is also invisible to other tabs, which is why the storage event, in Section 34, matters for multi-tab pages. And a variable can be changed from anywhere in the code, which is why changing it only through dedicated functions, rather than direct assignment everywhere, keeps it manageable. But as a starting point, variables are the right home for state: they are simple, they are inspectable in the console, and they make the page's current situation explicit in a way that state hidden in the DOM never is. Start by naming your state variables clearly, giving them sensible initial values, and treating them as the page's memory.

A state variable's initial value deserves as much thought as its later changes, because it determines what the visitor sees first. An empty list, a default theme, and a filter of 'all' are sensible initials, because they describe a page with nothing yet chosen and nothing yet done. An initial value that is arbitrary, such as a filter set to a category the visitor did not choose, will confuse the first visit, because the page will appear to have made a choice on the visitor's behalf. Choose initials that mean 'nothing yet'.

Check: Give an example of state held in a single variable, and state held in an array.

Section 5 — State hidden in the DOM

Before a developer learns to hold state in variables, they often hold it, unknowingly, in the DOM itself. A task's completion is recorded by whether its list item has a "done" class. A filter's setting is recorded by which button looks active. A count is recorded by the number written in a span on the page. The DOM becomes the state, and the code reads the state back from the DOM whenever it needs it, by checking classes, reading text, or counting elements. That approach feels natural, because the DOM is visible and tangible, and for a tiny page it can even work. But it has weaknesses that grow with the page, and understanding them is the reason this chapter insists on variables as the home for state.

The first weakness is that the DOM is a display, not a data structure, and it is a poor place to store information that is not currently visible. A filtered-out task, for example, is not in the displayed list, so a DOM-based state would lose it entirely, or would need to keep it hidden somewhere, which is a second, invisible DOM state. A count derived from visible elements will be wrong when some elements are hidden. A task's identifier, needed to update or delete it, must be smuggled into the DOM as a data attribute, which is workable but fragile, because the DOM's structure and the data's structure are now tangled together. Changing the layout risks breaking the data, and changing the data risks breaking the layout, because they are the same thing.

The second weakness is that reading state from the DOM is slow, error-prone, and hard to test. Checking classes, parsing text back into numbers, and counting elements are all operations that can go wrong in ways that reading a variable cannot: a class name changes during a restyle and the state reading breaks; a number formatted for display, such as "1,000", fails to parse back into a number; a hidden element is counted or not counted depending on how the counting is written. State in a variable, by contrast, is read by naming it, and its type is the type you chose, not a string that must be parsed. The DOM should be the output of state, not its storage, and moving state out of the DOM and into variables, with rendering as the bridge, is one of the most important refactorings a front-end developer learns. It is the step that turns a page that works by coincidence into a page that works by design.

A further weakness of DOM state is that it cannot be saved easily. To persist a list whose state lives in classes and text, your code must scrape the DOM, reading each element's class and text and reconstructing the data, a process that is error-prone and that breaks whenever the markup changes. State in variables, by contrast, is already in a form that can be stringified and saved, without scraping, because it was never turned into display in the first place. Saving from variables is direct; saving from the DOM is reconstruction.

Check: Why is storing state in DOM classes or text a fragile approach?

Section 6 — Rendering from state

Rendering from state means building the display by reading the state variable and producing HTML that reflects it, every time the display needs to update. The rendering function takes the state as its input, loops over it, creates an element or a string for each item, and puts the result into the page. It does not read the old display to decide what to show; it starts from the state, fresh, each time. That fresh-start approach has a great virtue: the display can never drift from the state, because it is always rebuilt from the state, and any direct edit to the display, any stale element, or any leftover from a previous state is discarded in the rebuild. The rendering function is the single bridge between state and display, and it is the only place where the two meet.

A rendering function should be pure in a practical sense: given the same state, it should produce the same display, without depending on hidden variables or on the current contents of the page. That purity makes it testable: you can call it with a known state and inspect the result, without needing to set up a complex page situation first. It also makes it reusable: the same function can render the initial state on page load, the state after adding an item, and the state after filtering, because it does not care how the state got to be what it is, only what it is now. The pattern for using it is consistent: change the state, then call render, and the page updates. That two-step pattern, change then render, appears in every stateful feature in this chapter, and it is worth memorising as a rhythm: state changes first, display follows, never the other way round.

Rendering from scratch on every change can be inefficient for very large lists, because rebuilding thousands of elements for a single small change wastes work, and Section 18 discusses when to update a single element instead. For the lists in this book's exercises, with tens or hundreds of items, rebuilding is fast enough that its simplicity outweighs its cost, and it is the right default. A related point is escaping, from Chapter 22: when rendering builds HTML from state that contains visitor-typed text, it must treat that text as text, using .textContent or equivalent, not as HTML, or a task containing markup could break the page or run code. Rendering from state, done safely and from scratch, is the foundation on which the rest of state management rests, because it guarantees that what the visitor sees is what the state says, no more and no less.

A rendering function should also be careful to clear its container before building the new display, or to replace its contents entirely, rather than appending to what is already there. Appending without clearing will duplicate items on every render, because each render adds a fresh copy of the list to the existing one, and the list will grow with every interaction. Replacing the container's content, by assigning to innerHTML or by removing old children first, ensures that each render shows exactly the current state, no more and no less.

Check: What does a rendering function take as its input, and what does it produce?

Section 7 — Updating state, then rendering

The order of operations in a stateful page is fixed: first update the state, then render the new state. That order matters, because rendering reads the state, and if you render before updating, you will display the old state, and the visitor will see no change, or a change that lags one step behind their action. A todo list where clicking "add" renders before the new task is added to the array will show the list without the new task, and the visitor will click again, adding a duplicate, because the first click appeared to do nothing. The fix is not to render twice, or to patch the display directly, but to get the order right: push the new task into the state array, then call render, which will now include the new task, because it reads the updated array. That sequence, update then render, is the heartbeat of a stateful page.

Keeping that order consistent also simplifies event handlers. An event handler for adding a task has three jobs: read the input, update the state, and render. An event handler for toggling a task has the same three jobs, with a different update. An event handler for deleting has the same shape again. When every handler follows the same shape, the code becomes predictable, and a bug in one handler is likely to be a bug in its update step, not a mysterious display problem. It also makes it easy to add a persistence step, from later in this chapter, in the same place: update the state, save the state, render the state, in that order, so the saved copy always matches the displayed copy, because both are derived from the same updated state.

There is a temptation to skip the render step when the update seems small, by patching the display directly for that one case, and that temptation should be resisted, because it reintroduces the state-display split this chapter warns against. A direct patch may work for that case, but the next render from state will overwrite it, and the page will flicker between the patched display and the true display, confusing the visitor and the developer alike. If rendering from scratch feels too heavy for a particular update, the answer is a targeted rendering function that still reads the state, such as updating one element from one item's state, as Section 18 describes, not a patch that bypasses state entirely. The discipline is simple to state: no display change without a state change first, and no state change without a render afterwards. A page that follows that discipline will never show a state it does not hold, and will never hold a state it does not show.

Check: What happens if you render before updating the state?

Section 8 — The todo list as a state example

The todo list is the classic example for state management, not because todo lists are uniquely important, but because they contain, in miniature, almost every state problem a front-end developer will meet. A todo list has a collection of items, each with text, a completion flag, and an identifier. It has operations: adding, removing, toggling, and editing items. It has derived views: all tasks, active tasks, and completed tasks, selected by a filter. It has counts: how many tasks remain, how many are done. It has persistence: the list should survive a refresh, which brings in local storage. And it has a user interface that must reflect all of that, immediately and correctly. A developer who can build a todo list well, with clean state, rendering, and persistence, has the skills to build a basket, a playlist, a message list, or any other collection-based feature, because the underlying shape is the same.

The state for a todo list is best held as an array of objects, where each object describes one task. A typical object might have an id, a unique number or string that identifies the task even if two tasks have the same text; a text field, holding what the visitor typed; and a done field, a boolean holding whether the task is complete. That shape makes each operation straightforward: adding appends a new object, removing filters the array to exclude one id, toggling finds the object by id and flips its done flag, and filtering for display selects objects by their done flag without changing the array itself. The identifier deserves emphasis, because using the task's text or its position in the list as its identity breaks when two tasks share the same text, or when a task is removed and positions shift. A unique id, generated when the task is created, perhaps from the current time plus a counter, gives each task a stable identity that survives reordering and duplication.

Beyond the list itself, a todo list has UI state: the current filter, the text in the new-task input, and perhaps whether an item is being edited. Those pieces of UI state can live alongside the list in a single state object, or in separate variables, depending on the page's size, and Section 16 discusses grouping them. The point of the example is not the list itself, but the pattern it teaches: a collection as an array of identified objects, operations as functions that change that array, derived views as functions that select from it, and rendering as a function that displays the selected view. Learn that pattern on a todo list, and you will recognise it everywhere, because most front-end state is either a single value, a collection of identified items, or a combination of the two, and the todo list contains both.

Check: What fields should each todo item object have, and why does it need an id?

Section 9 — Adding to state

Adding an item to a collection state is the simplest state change, and it is worth doing carefully, because it establishes the pattern for all other changes. To add a todo task, you first read the new task's text from the input, trim it, and validate it, rejecting an empty string with a message rather than adding a blank task. Then you create a new object for the task, with a unique id, the trimmed text, and a done value of false. Then you add that object to the state array, and finally you render and, if persistence is in use, save. The creation of the object is a distinct step from adding it, because the object must be complete and valid before it enters the state; adding a half-formed object, and then filling it in afterwards, leaves a window in which the state holds an invalid item, and any rendering or saving in that window will propagate the invalidity.

How you add to the array matters for a subtle reason. The method .push() adds to the end of the existing array, changing it in place, while creating a new array with the spread syntax, such as [...tasks, newTask], creates a fresh array containing the old items plus the new one, leaving the old array untouched. Both achieve the same visible result, and for this book's plain JavaScript exercises, either is acceptable. The distinction becomes important in frameworks and in larger programs, where detecting that state changed may depend on whether the array's identity changed, and where code holding a reference to the old array should not see it change unexpectedly. For now, the lesson is to be aware that there are two styles, in-place mutation and creating a new array, and to choose one consistently within a project, so the code's behaviour is predictable. The practical task at the end of this chapter uses the simpler push style, and that is fine, provided the render step follows the update.

After adding, two housekeeping steps complete the operation. The input should be cleared, so the visitor can type the next task without deleting the previous one, and focus should return to the input, so they can continue adding without clicking again. Those small courtesies make a list feel efficient to use, especially for a visitor adding several tasks in a row. The saved state, if persistence is enabled, should also be updated at this point, so a refresh after adding does not lose the new task. Adding is therefore not a single line of code, but a small sequence: validate, create, add, save, render, clear, and refocus. That sequence, written as a function, is reusable and testable, and it is the template for the other operations in the next sections, each of which follows the same rhythm with a different change at its heart.

Check: What steps should an add-item handler perform, in order?

Section 10 — Removing from state

Removing an item from a collection state means producing a state that no longer contains that item, and the cleanest way to do it is with the array method .filter(). Filter takes a test function and returns a new array containing only the items for which the test returns true. To remove the item with a given id, you filter for items whose id is not that id, and assign the result back to the state variable. Every other item is kept, in its original order, and the removed item is gone, without disturbing the others. That approach avoids the pitfalls of removing by position, such as splicing at an index that shifts when items are added or removed, and it works even when the list is filtered for display, because the removal is by stable id, not by visible position, which may differ from the state's position when a filter is active.

The user interface for removal needs care, because deletion is irreversible in a simple list. A delete button beside each item is the usual control, and clicking it should remove that item's id from the state, save, and render. For important items, a confirmation step, as Chapter 22 notes for server deletions, is wise, though for a personal todo list a single click is often acceptable, especially if an undo is offered, a topic Section 45 touches on. What matters most is that the button is clearly associated with its item, so the visitor does not delete the wrong one, and that the button's handler receives the correct id, not the item's position in the displayed list. Passing the id through a data attribute on the button, and reading it in a delegated handler, as Section 19 describes, is a robust way to achieve that association, because the id travels with the button, regardless of filtering or reordering.

After removal, the same housekeeping as for adding applies: save the new state, so the removal persists, and render, so the display matches. There is also a derived-value consequence: counts of remaining or total tasks, which are computed from the state, will change when an item is removed, and they will update automatically if they are computed during rendering, rather than stored separately. That is the benefit of derived values, covered in Sections 12 and 13: they cannot go stale, because they are recomputed from the current state each time. Removing, like adding, is therefore a short sequence: identify by id, filter the state, save, and render, with counts and views updating as a consequence, not as separate steps that might be forgotten. A list whose removal works by id, through filter, and whose display follows from state, will behave correctly under filtering, reordering, and persistence, which is the standard a state implementation should meet.

Check: Why is removing by id safer than removing by position in a displayed list?

Section 11 — Toggling and updating an item

Toggling a task's completion, and updating an item more generally, means finding one item in the state and changing one of its fields, while leaving the rest of the state untouched. For a todo list, toggling means finding the task by its id and flipping its done boolean from false to true, or from true to false. The finding step is usually done with the array method .find(), which returns the first item matching a test, or by looping over the array until the id matches. Once found, the change itself is a single assignment, such as task.done = !task.done, which flips the boolean to its opposite. That flip is the whole update for a toggle, and its simplicity is deceptive, because it must be followed by the same save and render steps as adding and removing, or the display will not reflect the change and the change will not persist.

Updating a field other than a boolean follows the same pattern with a different assignment. Editing a task's text means finding the task by id, validating the new text, assigning it to the task's text field, and then saving and rendering. Updating a quantity in a basket means finding the item by id, changing its quantity, perhaps with a check that the quantity stays within sensible bounds, and then saving and rendering. In each case the structure is: find by id, validate the new value, assign it, save, render. That structure is worth memorising, because it is the template for every single-item update, and it keeps the code's shape consistent across features. A handler that finds by position, or that updates the display directly without touching the state, breaks the template and invites the bugs this chapter warns against, such as updating the wrong item after filtering.

There is a subtlety about finding items that deserves attention. The .find() method returns a reference to the object in the array, not a copy, so assigning to its fields changes the object in the state array directly. That direct change is convenient, and it is the style used in this book's plain JavaScript examples, but it means the state array's contents have changed without the array itself being replaced, which matters in frameworks that detect changes by array identity. In plain JavaScript with explicit rendering, it does not matter, because you call render yourself after the change, and render reads the current contents regardless of how they changed. What matters is that you do call render, and that you save, after every update, without exception. A toggle that changes the state but does not render will look broken to the visitor, who clicked and saw nothing happen, and a toggle that renders but does not save will look correct until the page is refreshed, when it will revert, which is a subtler and more frustrating failure. Find, change, save, render: that is the complete update.

Check: What are the steps for toggling a single item's completion?

Section 12 — Filtering state for display

Filtering for display means selecting a subset of the state to show, without changing the state itself. A todo list with a filter of "active", "completed", or "all" does not delete completed tasks when the active filter is chosen; it simply hides them for the moment, and they return when the filter changes back to "all". That distinction, between the stored state and the displayed view, is essential. The filter operation takes the full state array and the current filter setting, and returns a new array containing only the items that match the filter, using .filter() with a test that depends on the filter: for "active", keep items where done is false; for "completed", keep items where done is true; for "all", keep everything. The rendering function then displays that filtered array, not the full state, and the full state remains intact underneath, ready for the next filter.

Keeping the filter as state, separate from the list, is what makes this work. The filter setting itself is a piece of UI state, perhaps a string variable holding "all", "active", or "completed", changed when the visitor clicks a filter button, and the displayed list is derived from the list state plus the filter state together. When either changes, the page re-renders, recomputing the filtered view from the current values of both. That recomputation is the key: the filtered view is never stored, it is always computed, so it cannot go stale. If a task is toggled from active to completed while the "active" filter is selected, the next render will recompute the filtered view and the task will disappear from the active view, correctly, because it no longer matches the filter, even though it still exists in the full state. A design that stored the filtered view separately would need to update it on every toggle, an extra step that would inevitably be forgotten somewhere.

Filtering for display also interacts with operations such as removal and editing, and the id-based approach from earlier sections is what keeps those operations correct under filtering. A delete button in a filtered view must delete by the item's id from the full state, not by its position in the filtered view, because the filtered position differs from the full state's position. Similarly, a count of "items shown" is a property of the filtered view, while a count of "items remaining" is a property of the full state, and confusing the two will show wrong numbers. Naming those counts clearly, and computing each from the appropriate source, prevents that confusion. Filtering is therefore not just a display trick; it is a lesson in derived views, in keeping the full state intact while showing a part of it, and in computing views fresh on each render, so the display always reflects the current state and the current filter together.

Check: Does choosing the "active" filter delete completed tasks? Why not?

Section 13 — Derived values and counts

A derived value is a value computed from state whenever it is needed, rather than stored separately. The count of remaining tasks in a todo list is a derived value: it is the number of items in the state whose done flag is false, computed by filtering for undone items and taking the resulting list's length. The total number of tasks is the state's length. The count of completed tasks is the total minus the remaining, or the length of the filtered completed list. None of those counts needs its own variable, because each can be computed from the state in one line, at render time, and each will then always be correct, because it is recomputed from the current state on every render. Storing a count separately, and updating it whenever the state changes, is a second source of truth, and it will eventually disagree with the state, when an update path forgets to update the count, or updates it incorrectly.

Derived values appear everywhere in stateful pages. A basket's total price is derived from its items' prices and quantities. A form's validity, in the sense of whether all fields pass, is derived from the fields' values. A pagination control's "page 2 of 5" is derived from the total item count and the page size. A search result's "showing 10 of 47" is derived from the filtered list and the page size. In each case, the derived value is a function of the state, and the rendering function is the natural place to compute it, because rendering already reads the state to build the display, and the derived value is part of that display. Computing derived values during rendering keeps them close to their use, and it ensures they are recomputed whenever the display is recomputed, which is whenever the state changes.

There is a performance consideration, though it rarely matters at this book's scale. Recomputing a derived value on every render is cheap for a list of hundreds of items, but for a very large list or an expensive computation, recomputing on every keystroke could become noticeable. In those cases, a technique called memoisation, caching a computed value until its inputs change, can help, and frameworks often provide it automatically. For plain JavaScript exercises, recomputing is the right default, because its correctness outweighs its cost, and premature optimisation for a problem you do not yet have will complicate the code without benefit. The rule to carry forward is: if a value can be computed from state, compute it, do not store it, and let the rendering function be the place where that computation happens. A page whose counts, totals, and summaries are all derived will never show a count that disagrees with its list, and that agreement is a quiet but powerful sign of a well-built page.

Check: Why should a remaining-tasks count be computed from state instead of stored in its own variable?

Section 14 — Form state

A form's state is what the visitor has typed, chosen, or ticked so far, and it exists whether or not you name it as state. In a simple form, the fields themselves hold that state, in their values, and your code reads them at submit time, as Chapter 21 describes. That approach works for a form that is filled and submitted in one go, with no need to preserve its contents across renders or to display derived information from it while typing. But as soon as a form gains features that depend on its contents during typing, such as a live preview, a character count, a conditional section that appears when a checkbox is ticked, or a draft that is saved as the visitor types, the form's state needs to be named and managed explicitly, rather than read from the fields only at submit time.

Naming form state means holding the field values in variables or in an object, updating them on input events, and, where needed, rendering parts of the page from them. A live preview of a message, for example, reads the message state and displays it in a preview area, updating on each input event, so the visitor sees how their message will look before submitting. A conditional section, such as delivery details that appear only when home delivery is chosen, reads the delivery-method state and shows or hides the section accordingly, on each change event. In both cases, the state is the source, and the display follows, in the same pattern as the rest of this chapter. The fields themselves are still the visitor's way of changing the state, but the state variable is the page's record of it, and other parts of the page can read that record without reaching into the fields directly.

There is a balance to strike. Holding every keystroke of every field in state variables, and re-rendering the whole form on each one, is unnecessary for a simple form, and it can interfere with the visitor's typing, for example by moving focus when the form re-renders. A practical approach is to hold form state explicitly only where it is needed: for the draft that must be saved, for the preview that must update, for the condition that must show or hide a section, and to read fields directly at submit time for the rest. That selective approach keeps the code simple where simplicity suffices, and adds state management where it earns its keep. The lesson is not that every form needs a full state object, but that a form's contents are state, and whenever another part of the page depends on those contents, or those contents must survive beyond the form's lifetime, naming that state explicitly is the way to manage the dependence reliably.

Check: When does a form's contents need to be held as explicit state, rather than read only at submit time?

Section 15 — Controlled inputs in plain JavaScript

A controlled input is an input whose value is set by the program, from state, rather than left to the browser to manage on its own. In plain JavaScript, most inputs are uncontrolled by default: the visitor types, the browser updates the field's displayed value, and your code reads it when needed. A controlled input inverts that flow: the state holds the value, the input's displayed value is set from the state, and when the visitor types, your code updates the state and then sets the input's value from the state again. That round trip gives the program control over the value, allowing it to transform it as it is typed, for example by formatting a phone number, uppercasing a code, or rejecting characters that are not allowed, in a way that an uncontrolled input cannot, because the browser would simply accept whatever was typed.

The term "controlled" comes from frameworks, where it is a standard concept, but the idea is implementable in plain JavaScript, and understanding it helps you read framework code later. To make an input controlled in plain JavaScript, you listen for its input event, read the typed value, transform it if needed, store the result in state, and assign the state back to the input's value. That assignment must be done carefully, because setting an input's value programmatically moves the cursor to the end of the field in some browsers, which is annoying for a visitor editing the middle of a value. For simple transformations, such as uppercasing, that cursor jump may be acceptable, but for complex formatting it can become a real usability problem, and it is one reason many plain JavaScript forms leave inputs uncontrolled and validate at submit time instead, reserving control for cases where it is genuinely needed.

The decision between controlled and uncontrolled inputs is therefore a trade-off. Uncontrolled inputs are simpler, they preserve the browser's native editing behaviour, including cursor position, undo, and autofill, and they are sufficient for most forms, where the value is only needed at submit time. Controlled inputs give the program the power to shape the value as it is typed, at the cost of complexity and potential interference with editing. A good rule is to start uncontrolled, and to add control only for a specific feature that requires it, such as live formatting or a value that must be kept in sync with another part of the page. When you do add control, keep the transformation simple, test the cursor behaviour, and ensure that the state and the input never disagree, because a controlled input whose state and display diverge is worse than an uncontrolled input, which at least shows what the visitor typed.

Check: What is the difference between a controlled and an uncontrolled input?

Section 16 — Keeping state in one object

As a page grows, its state often grows from a single variable into several: a list, a filter, a search term, a page number, a loading flag. Keeping those as separate variables works, but it becomes harder to see the whole state at a glance, to save it, or to pass it to a function that needs several pieces at once. Grouping them into a single state object solves that, by giving the page's state one home with named fields for each piece. A state object for a todo page might look like { tasks: [], filter: "all", search: "" }, where each field is a piece of state, and the object as a whole is the page's state. Reading a piece is then state.tasks or state.filter, and updating a piece is assigning to that field, followed by saving and rendering, in the familiar pattern.

A single state object has several practical benefits. Saving becomes one operation: stringify the whole object and store it under one key, rather than saving each variable under its own key and remembering to load each one back. Loading becomes one operation too: parse the stored object and assign it to the state variable, with defaults for any missing fields. Debugging becomes easier: logging the state object shows the whole situation in one console entry, rather than several variables that must be logged separately. And passing state to a function becomes simpler: a rendering function can take the whole state object and derive everything it needs from it, rather than taking five separate arguments that must be kept in order. That simplicity is worth the small cost of longer property accesses, such as state.filter instead of filter.

There are also cautions. A single object can grow large and unwieldy if every minor piece of UI state is added to it, including transient values such as whether a tooltip is visible, which may be better left as local variables or as DOM state. Grouping should follow meaning: pieces that change together, are saved together, or are needed together belong in the same object, while pieces that are independent and transient may live elsewhere. Another caution is that assigning to a field of the state object changes the object in place, which, as with arrays, is fine in plain JavaScript with explicit rendering, but is worth noting for later framework work. The principle to carry forward is not that all state must live in one object without exception, but that related state should be grouped, so the page's situation can be seen, saved, and reasoned about as a whole, rather than as a scattered collection of variables whose relationships are only in the developer's head.

A single state object also makes it easier to snapshot state for undo, as Section 45 describes, because one object, copied, captures the whole situation, rather than several variables that must each be copied separately. That snapshot completeness matters: an undo that restores the list but not the filter will leave the page in a mixed state, part old and part new, which is confusing. A single object, snapshotted as a whole, restores everything together, keeping the page's situation coherent across undo and redo.

Check: What are the benefits of grouping related state into one object?

Section 17 — Functions that change state

State should be changed through functions, not through scattered assignments throughout the code, and that discipline becomes more valuable as a page grows. A function such as addTask(text), toggleTask(id), or setFilter(filter) encapsulates one change: it validates its input, updates the state, saves the state if persistence is in use, and calls render, so the display follows. Every part of the page that needs that change calls the function, rather than duplicating its steps, which means the change's logic lives in one place, and fixing a bug in it fixes it everywhere. It also means the function can be tested in isolation: call it with a known input, and check that the state changed as expected, without needing to simulate a click or a form submission.

Functions that change state also give those changes names, which makes the code readable as a story about the page's behaviour. An event handler that calls addTask(text) says what it does in plain language, while a handler that pushes to an array, saves, and renders, inline, requires the reader to infer the intention from the steps. Naming the change also makes it easier to add related behaviour later: if adding a task should also clear the input and return focus to it, that housekeeping belongs inside addTask, or in the handler that calls it, in one place, rather than in every place that adds a task. And if a change needs to be guarded, such as preventing duplicate tasks or limiting the list's size, the guard belongs inside the function, where it will apply to every caller, not in one handler that another caller might bypass.

There is a related discipline: state-changing functions should be the only places that assign to state variables. Event handlers read inputs and call state functions; rendering functions read state and produce display, but never change state; saving functions read state and write it to storage, but never change it. That separation of roles, handlers as callers, state functions as changers, rendering as readers, and saving as readers, gives each part of the code a clear job, and it makes the flow of a change traceable: an event leads to a state function, which leads to a save and a render, in that order, every time. A page built that way is easier to debug, because a wrong state can be traced to the state function that produced it, and easier to extend, because a new feature can reuse existing state functions instead of inventing new, parallel ways to change the same state.

State functions should also validate their arguments, even when they are only called from your own handlers, because a function that accepts any value without checking will happily store an invalid one, such as an empty task text or a negative quantity, and the invalid state will then propagate to rendering and storage. A guard at the top of the function, returning early or throwing for invalid input, protects the state from every caller, including future callers you have not yet written, and it keeps invalid data out of the page's truth.

Check: Why should state changes go through named functions instead of scattered assignments?

Section 18 — Re-rendering the whole list versus one element

Rendering from scratch, rebuilding the entire list on every change, is simple and correct, and it is the right default for lists of the size in this book's exercises. But it has a cost that grows with the list: rebuilding a thousand elements because one task was toggled wastes work, and it can have side effects, such as losing the focus of an input inside the list, or resetting the scroll position, or interrupting an animation. For large lists, or for lists containing inputs that the visitor is editing, updating a single element in place can be better. That targeted update finds the element for the changed item, by its id, and updates only its text, its class, or its checked state, leaving the rest of the list untouched, so focus, scroll, and animation are preserved, and the work done is proportional to the change, not to the list's size.

Targeted updating has its own costs, which is why it is not the default. It requires a reliable mapping from items to elements, usually through a data attribute holding the item's id, and it requires code that knows, for each kind of change, which part of the element to update: a toggle changes a class and a checkbox, an edit changes text, a deletion removes the element. That code is more detailed than a from-scratch render, and it can drift from the rendering logic, so a change that is handled in the from-scratch render but not in the targeted update will show correctly on a full render and incorrectly on a targeted one. Keeping the two in agreement requires care, and in practice many developers choose a hybrid: from-scratch rendering for structural changes, such as adding, removing, or filtering, where the list's shape changes, and targeted updating for small, frequent changes, such as toggling, where the shape stays the same and only one element's appearance changes.

The decision between the two is a judgement about scale and about side effects, not a rule to be followed blindly. For a list of dozens of items, from-scratch rendering is fast enough that its simplicity wins, and targeted updating would add complexity without a perceptible benefit. For a list of thousands, or a list with editable inputs, targeted updating earns its complexity by preserving focus and by keeping the page responsive. A useful middle path is to re-render only the container for the changed section, rather than the whole page, which limits the rebuild to the relevant part without requiring per-element code. Whichever approach you choose, the principle from earlier sections remains: the update must be driven by state, reading the changed item's state to decide what its element should show, not by toggling the element directly and hoping the state will catch up.

Check: When is updating a single element better than re-rendering the whole list?

Section 19 — Event delegation for stateful lists

A stateful list presents an event-handling problem: its items are created and destroyed as the state changes, so attaching a listener to each item's button at creation time is fragile, because a re-render replaces the buttons and their listeners with new ones that have none. Event delegation solves this by attaching a single listener to the list's container, which persists across renders, and letting events from the buttons bubble up to it. When a click occurs on a button inside the list, the event bubbles up through its ancestors to the container, and the container's handler examines the event's target, the element actually clicked, to determine which button it was and which item it belongs to. That determination is usually made by finding the closest button with a particular class, and reading the item's id from a data attribute on the button or on its parent list item.

Delegation has several benefits beyond surviving re-renders. It uses one listener instead of one per item, which is more efficient for long lists, and it automatically handles items added later, because the container's listener is already in place and will receive their bubbled events without any new attachment. It also centralises the list's event logic in one handler, which can branch on the button's class or action, such as "delete", "toggle", or "edit", and call the appropriate state function with the item's id. That central handler is easier to read and to maintain than listeners scattered across creation code, and it keeps the rendering function focused on display, rather than mixing display creation with listener attachment. The pattern is: render creates elements with data attributes and classes, and a single delegated handler on the container interprets clicks on those elements.

There are details to get right. The handler must check that the click actually came from a button it cares about, and ignore clicks elsewhere in the container, such as on the list item's text, by testing the target's closest button and returning early if there is none. It must read the id from the correct element, usually the button's or the list item's data attribute, and convert it to the right type if ids are numbers stored as strings in attributes. And it must be aware that some elements, such as links or form controls inside a list item, may have their own default behaviour that needs preventing, though for buttons in a list that is rarely an issue. Delegation is a technique that appears in many stateful interfaces, from todo lists to comment threads to product grids, and mastering it removes a whole class of bugs where buttons stop working after a re-render, which is one of the most confusing failures for a beginner to encounter.

Check: Why does a single listener on a list container survive re-rendering when listeners on individual buttons do not?

Section 20 — Saving state: why persist

State held in variables disappears when the page is closed or refreshed. For a todo list, that means a visitor who carefully builds a list, then refreshes the page or closes the browser, returns to an empty list, and their work is lost. That loss is not a minor inconvenience; it breaks trust, because the visitor reasonably expects a list they created to still be there later, just as a document they saved remains when they reopen it. Persistence is the name for saving state somewhere that survives a page close, so it can be loaded back on the next visit, and it is what turns a toy list into a useful tool. The most accessible persistence mechanism in a browser is local storage, covered in the next sections, which stores data on the visitor's own device, under the page's origin, and makes it available to that origin on later visits.

Persistence changes the user's relationship with a page. A page without persistence is a session: useful while it is open, forgotten when it is closed. A page with persistence is a tool: the visitor can return to it, continue where they left off, and build up data over time, without needing an account or a server. That is a powerful capability for a front-end developer, because it requires no back end, no database, and no sign-in, yet it gives the page a memory that spans visits. It also changes the developer's responsibilities, because persisted data must be handled with care: it must be loaded correctly, it must survive the page's own updates, it must not be corrupted by a failed save, and it must be deletable when the visitor wants it gone. Those responsibilities are the subject of the storage sections that follow, and they are worth taking seriously, because persisted data is data the visitor trusts you with, even if it never leaves their device.

There are limits to browser persistence that shape its use. Local storage is tied to an origin and to a device: data saved on a phone is not available on a laptop, and data saved on one site is not available to another, which is a privacy protection, but also a limitation for visitors who use several devices. Storage is also limited in size, and it can be cleared by the visitor, by the browser, or by private browsing modes, so it is not a substitute for a server database for important data. For a personal todo list, a theme preference, or a form draft, those limits are acceptable, because the data is personal, device-specific, and recoverable if lost. For shared, critical, or large data, a server is needed, and browser storage is at most a cache or a draft. Understanding persistence as a device-local, origin-bound, limited memory, useful for personal tools but not a replacement for a server, sets the right expectations for what local storage can and cannot do.

Check: Why does a todo list need persistence, and what happens without it?

Section 21 — What localStorage is

Local storage is a small, key-value store built into every modern browser, available to JavaScript through the localStorage object. It lets a page save strings under names, called keys, and read them back later, even after the page is closed and reopened, as long as the visitor returns to the same origin, meaning the same scheme, domain, and port, on the same device and browser. The data is stored on the visitor's device, not on a server, so it is available offline, it does not require an account, and it never leaves the device unless your code sends it somewhere. That locality is both its strength and its limit: it is private to the origin and the device, which protects it from other sites, but it also means it cannot be shared across devices or sites.

The key-value model is simple: each entry has a key, a string naming it, and a value, also a string, holding the data. A page might store a theme under the key "theme" with the value "dark", a username under "username", or a whole todo list under "tasks", with the list encoded as a JSON string, a technique Section 24 explains. Keys are scoped to the origin, so two sites can both use the key "theme" without interfering, because each origin has its own separate store. Within an origin, keys are shared across all its pages, so a setting saved on one page of a site is available on another page of the same site, which is useful for site-wide preferences such as theme. The store is also persistent in the sense that it has no expiry date: data remains until your code deletes it or the visitor clears it, unlike session storage, which ends when the tab closes, as Section 31 explains.

Local storage is synchronous, meaning its methods return immediately and block the page while they run, which is fine for small values but can become noticeable for large ones. It is also limited in size, typically around five megabytes per origin, though the exact limit varies by browser, and exceeding it throws an error, as Section 30 explains. Those characteristics shape how it should be used: for small, personal, device-local data, such as preferences, drafts, and small lists, it is ideal; for large data, for data that must be shared across devices, or for data that must never be lost, it is not sufficient, and a server is needed. As a first persistence tool, though, local storage is unmatched for simplicity: two methods, setItem and getItem, are enough to give a page a memory, and the next section introduces them.

Local storage is also synchronous in a way that matters for large values: reading or writing a large string will block the page briefly, because the browser must complete the operation before continuing. For small preferences and lists that block is imperceptible, but for a very large draft it could cause a noticeable pause, which is another reason to keep stored values small and to debounce saves for long texts, as Section 38 recommends. Small, synchronous storage is simple and fast; large, synchronous storage is a performance risk.

Check: What is localStorage, and where is its data stored?

Section 22 — setItem and getItem

Local storage is used through two main methods. localStorage.setItem(key, value) saves a value under a key, overwriting any previous value for that key. localStorage.getItem(key) reads the value back, returning it as a string, or returning null if no value has been saved under that key. Those two methods, plus removeItem and clear, covered in Section 28, are the whole basic interface, and their simplicity is part of local storage's appeal. Saving a theme, for example, is localStorage.setItem("theme", "dark"), and loading it on the next visit is localStorage.getItem("theme"), which returns "dark" if it was saved, or null if it was not, in which case your code should use a default, such as "light".

localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme");

The null return for a missing key is an important detail, because it is how your code distinguishes "nothing saved yet" from "something saved". A first-time visitor will have no saved theme, so getItem returns null, and your code should treat that as "use the default", not as an error. A visitor who saved a theme will have a string, and your code should use it. That pattern, read, check for null, use the saved value or a default, appears in every loading routine, and it is worth writing as a small helper function, so the null check is not forgotten in one place. Another detail is that setItem converts its value to a string automatically, so saving a number will store its string form, and reading it back will give a string, not a number, which is the subject of the next section. That automatic conversion is convenient for strings, but it is a trap for numbers, booleans, and objects, which need explicit handling.

Saving should happen at the right moment, which is whenever the state changes, in the same sequence as rendering: update the state, save the state, render the state. Saving on every change keeps the stored copy in step with the live state, so a refresh at any moment restores the latest state, not a stale one from an earlier save. Saving only on page unload, by contrast, is unreliable, because the page may be closed without the unload handler running, or the browser may be killed, and the latest changes would be lost. Saving on change is slightly more work, because it happens more often, but for small values it is cheap, and it is the only way to guarantee that the stored copy matches the state the visitor last saw. Two methods, a null check, and a save-on-change habit are the complete basic practice for local storage.

A helper for getItem that returns a default when the key is missing can simplify loading code considerably. Instead of checking for null at every load site, a function such as getSavedTheme that returns the saved value or 'light' if none exists encapsulates the null check in one place, and every caller benefits. That helper pattern, applied to each stored value, reduces repetition and reduces the chance that one load site forgets the null check and crashes on a first-time visitor's empty store.

Check: What does getItem return when nothing has been saved under a key?

Section 23 — Local storage only stores strings

Local storage stores only strings. That single fact shapes everything about how it is used, and forgetting it is the source of a whole class of bugs. When you save a number with setItem("count", 42), the browser converts 42 to the string "42", and reading it back gives you "42", not 42. Adding 1 to that string gives "421", not 43, because JavaScript's plus operator concatenates strings. When you save a boolean true, you get back the string "true", which is truthy in a boolean context, but so is the string "false", because any non-empty string is truthy, so a test such as if (localStorage.getItem("darkMode")) will be true even when the saved value was "false". When you save an object, the browser converts it with its default string conversion, giving the useless string "[object Object]", losing all of the object's fields. Each of these is a consequence of the same rule: the store holds strings, and only strings, and your code must convert to and from strings explicitly.

The conversions for simple types are straightforward once you know they are needed. A number is saved with String(number) or by letting setItem convert it, and read back with Number(value), checking for NaN if the stored string might not be a number. A boolean is saved as "true" or "false", and read back by comparing the string to "true", such as value === "true", which correctly gives false for "false", unlike a truthiness test. A string needs no conversion, which is why theme names and usernames are the simplest values to store. For objects and arrays, the conversion is JSON, covered in the next section, which encodes the whole structure as a single string and decodes it back. The pattern in each case is the same: convert to a string on save, convert back to the original type on load, and handle the missing case, null, with a default.

A useful habit is to write small save and load helpers for each type you store, so the conversion is written once and reused, rather than repeated at every save and load site where it might be forgotten or done differently. A helper such as loadCount() that reads, converts with Number, and returns a default on null or NaN, encapsulates all three concerns, missing, conversion, and validation, in one place. Without helpers, a project accumulates scattered conversions, some correct and some not, and a bug in one place, such as a boolean read as truthy "false", can persist unnoticed because the code looks plausible. Local storage's string-only rule is not a flaw; it is a simplicity that makes the store easy to implement and to understand. But it places the responsibility for type fidelity squarely on your code, and meeting that responsibility explicitly, at every save and load, is part of using local storage correctly.

Check: Why does saving the boolean false and reading it back with a truthiness test give the wrong answer?

Section 24 — JSON for objects and arrays

Objects and arrays cannot be stored in local storage directly, because the store holds only strings, but they can be stored as JSON strings, which encode their full structure in text. The function JSON.stringify(value) turns an object or an array into a JSON string, and JSON.parse(text) turns that string back into an object or an array, as Chapter 22 explains for network data. The pair is the bridge between structured state and string storage: on save, stringify the state and store the resulting string; on load, read the string and parse it back into state. A todo list, which is an array of objects, is saved as localStorage.setItem("tasks", JSON.stringify(tasks)), and loaded as JSON.parse(localStorage.getItem("tasks")), with a null check and a default for the first visit. That two-line pattern is the standard way to persist any structured state in local storage, and it appears in almost every project that uses it.

localStorage.setItem("tasks", JSON.stringify(tasks));
const saved = localStorage.getItem("tasks");
const loadedTasks = saved ? JSON.parse(saved) : [];

There are details that make this pattern robust. First, JSON.stringify only preserves data that JSON can represent: strings, numbers, booleans, null, arrays, and plain objects. It does not preserve functions, dates as Date objects, or undefined values in the way you might expect: functions are dropped, dates become strings, and undefined array elements become null. For plain state, made of strings, numbers, booleans, and nested plain objects, that is fine, but state containing Date objects should store them as date strings and convert them back on load, explicitly, rather than assuming the parsed value is still a Date. Second, JSON.parse throws an error if the stored string is not valid JSON, which can happen if the data was corrupted, or if an earlier version of the page stored a different format, a case Section 27 handles. Wrapping the parse in a try and catch, and falling back to a default on failure, prevents a corrupted save from breaking the whole page on load.

Third, stringifying on every change, for a large state, can become a performance consideration, because it converts the whole state to a string each time, even if only one item changed. For the small states in this book, that cost is negligible, and the simplicity of saving the whole state at once outweighs it. For larger states, saving only the changed part, or debouncing saves, as Section 38 discusses for drafts, can help. The core idea, though, remains: structured state becomes a JSON string for storage, and a JSON string becomes structured state on load, with null and parse errors handled by defaults. That round trip, stringify on save and parse on load, is the technique that turns local storage from a store for single strings into a store for whole application states.

JSON storage also makes it easy to inspect saved state in developer tools, because the stored string is human-readable, unlike a binary format. Opening the storage view and reading the saved list, with its tasks and flags, lets you verify that saving worked, that the shape is as expected, and that no field is missing.

Check: How do you save an array of objects in localStorage, and how do you load it back?

Section 25 — Saving a todo list

Saving a todo list brings together the state, rendering, and storage patterns of this chapter into one working example, and it is worth walking through as a complete sequence. The state is an array of task objects, held in a variable. The save function takes that array, stringifies it, and stores it under a key, such as "tasks". The load function, run when the page first loads, reads that key, parses it if present, and assigns the result to the state variable, or uses an empty array if nothing was saved or if parsing failed. The rendering function displays the state, and every state-changing function, add, toggle, and delete, calls save after updating the state and before or after rendering, so the stored copy always matches the displayed copy. That sequence, load once at start, save on every change, render on every change, is the complete persistence loop for a list.

The order of save and render within a change deserves a moment's thought. Saving before rendering means that if rendering throws an error, the state is already saved, which is good, because the state itself is correct even if the display failed. Rendering before saving means that if saving throws, for example because the storage quota is exceeded, the display will show a change that was not saved, and a refresh will revert it, which is confusing. Saving first is therefore slightly safer, though in practice both orders work when neither step throws, which is the common case. What matters more than the order between save and render is that both happen, on every change, without exception, because a change path that forgets to save will lose that change on refresh, and a change path that forgets to render will look broken immediately. Centralising changes in state functions, as Section 17 recommends, makes it easier to guarantee both, because the save and render calls live in one place per change, or in a single "update" helper that all changes call.

Testing a saved todo list is also part of building it. Add a task, refresh the page, and confirm the task is still there. Toggle a task, refresh, and confirm its completion persisted. Delete a task, refresh, and confirm it is gone. Each of those tests checks one link in the persistence loop, and a failure in any one points to a specific step: a task that disappears on refresh was not saved, a toggle that reverts was not saved after toggling, and a list that never loads was not loaded correctly at start. Those tests are quick to run, and they catch the most common persistence bugs, which are almost always a missing save call on one change path, rather than a flaw in local storage itself. A todo list that passes all three refresh tests has a working persistence loop, and that loop is reusable for any other list-based feature.

Check: When should a todo list be saved: on every change, or only when the page closes? Why?

Section 26 — Loading state on page start

Loading state on page start is the counterpart to saving on change, and it must happen before the first render, or the page will briefly show its initial state and then jump to the saved state, a flicker that feels unpolished. The loading sequence is: read the stored string for the state's key, check whether it is null, and if it is, keep the default state, such as an empty list or a default theme. If it is not null, parse it, validate the result, and assign it to the state variable, using the parsed value only if it has the expected shape, and falling back to the default otherwise. Only then, with the state correctly loaded, should the page render for the first time, so the first display the visitor sees is the saved state, not a flash of the default. That order, load, then render, is as important at startup as update-then-render is during interaction.

Validation on load is not optional, because stored data cannot be trusted blindly. It may have been saved by an earlier version of the page with a different state shape, it may have been edited by the visitor in developer tools, or it may have been corrupted by a failed save. A load routine that assigns a parsed value without checking it may set the state to a string where an array is expected, or to an object missing a field the rendering code assumes, causing a crash on first render. A robust load routine checks the parsed value's type, for example confirming it is an array for a list, and, where feasible, checks that its items have the expected fields, discarding or repairing items that do not, rather than crashing. That defensive loading is the subject of the next section in more detail, but its principle belongs here: loading is not merely reading, it is reading, checking, and falling back safely.

There is also a question of where loading code lives. It should run early in the page's script, before any rendering and before any event handlers are needed, so the state is ready when the page becomes interactive. In a script at the end of the body, that means the load code appears before the first render call, at the top of the script's initialisation section. In a larger application, loading may be part of an initialisation function that sets up state, attaches handlers, and then renders, in that order. What matters is the sequence, not the exact structure: state must be loaded before it is displayed, and displayed before the visitor interacts with it, so the first interaction acts on the loaded state, not on a default that will be overwritten a moment later. A page that loads correctly shows the visitor, on arrival, exactly where they left off, which is the promise persistence makes, and keeping that promise starts with loading before rendering.

Check: Why must loading happen before the first render?

Section 27 — Handling missing or corrupt saved data

Saved data can be missing, and it can be corrupt, and a page must handle both gracefully, without crashing and without losing the visitor's trust. Missing data is the simpler case: a first-time visitor has no saved value, so getItem returns null, and the page should use its default state, as Section 26 describes. That is not an error; it is the normal state for a new visitor, and the page should behave as if it were freshly installed, with no message and no fuss. Corrupt data is the harder case: a saved string exists, but it is not valid JSON, or it parses to a value of the wrong shape, such as a string where an array is expected, or an array whose items lack the fields the rendering code needs. That can happen if an earlier version saved a different format, if a save was interrupted, or if the visitor edited the stored value in developer tools, and it must not break the page.

Handling corrupt data has two parts: detection and recovery. Detection means wrapping the parse in a try and catch, so a parse error is caught rather than thrown, and checking the parsed value's shape, such as confirming it is an array, before using it. Recovery means falling back to a sensible default, such as an empty list, and, ideally, clearing or overwriting the corrupt entry, so the next load does not encounter the same corruption again. The fallback should be silent for the visitor in most cases, because a message such as "Your saved data was corrupt" is alarming and unhelpful when the visitor cannot fix it, though logging the problem to the console for the developer is appropriate. In cases where the lost data matters, such as a long list, a brief, calm message acknowledging that saved data could not be restored, and inviting the visitor to start again, is more honest than silently showing an empty list as if nothing had been lost, but for most personal tools, a silent fallback to a default is acceptable.

There is a deeper lesson here about defensive programming at boundaries. Stored data, like network data, crosses a boundary into your program from outside, in this case from a previous session, and it should be treated with the same caution as any external input: check its type, check its shape, and have a fallback for when it does not match. Code that trusts stored data blindly will work for the common case and crash for the edge cases, which are exactly the cases where the visitor most needs the page to work, because they are returning to data they care about. A load routine that validates and recovers is a small amount of code, a try and catch, a type check, and a default, but it is the difference between a page that survives a corrupted save and a page that fails to load at all for a visitor who did nothing wrong.

Check: What should a page do when its saved data parses to the wrong shape?

Section 28 — removeItem and clear

Local storage provides two methods for deleting data, and they serve different purposes. localStorage.removeItem(key) deletes a single entry, by its key, leaving all other entries untouched. It is the right tool when a specific piece of saved state should be discarded, such as clearing a saved form draft after the form is successfully submitted, or resetting a single preference to its default. After removal, getItem for that key returns null again, just as for a never-saved key, and the page's load routine will fall back to its default, which is the desired behaviour for a reset. Removing a single key is a precise, safe operation, because it affects only the data you named.

localStorage.clear() deletes every entry in the origin's store, for all keys, in one call. It is a blunt instrument, and it should be used with caution, because it will delete not only the data your feature saved, but also any other data the same origin has saved, such as settings for other pages, or data saved by other features. A "reset this list" button that calls clear() will also reset the visitor's theme, their other lists, and any other saved state on the site, which is almost certainly not what the visitor intended. Clear is appropriate only for a deliberate, site-wide reset, such as a "delete all my data on this site" option in a settings page, where the visitor understands and intends the breadth of the deletion, and where a confirmation step has made that intention explicit. For feature-level resets, removeItem is the correct tool.

There is also a relationship between removing stored data and resetting live state that is easy to get wrong. Removing a key from storage does not change the live state variable, which still holds the old data until it is reset and re-rendered. A reset button must therefore do three things: reset the live state to its default, remove the stored key, and render the default state, so the live page, the stored copy, and the display all agree on the reset. Forgetting any one of those steps leaves a disagreement: a page that looks reset but reloads the old data, because the stored key was not removed, or a page whose storage is cleared but whose display still shows the old list, because it was not re-rendered. As with saving, deletion is a state change, and it follows the same pattern: change the state, update the storage, render, in a consistent order, so all three representations of the truth stay together.

Removing a key should also be confirmed when the data matters, because deletion from storage is not undoable unless you have kept a copy elsewhere. A reset button that removes a todo list's key without asking will destroy a visitor's list in one click, with no recovery, while a confirmation dialog, or an undo offer, gives them a chance to reconsider. That confirmation is especially important for clear, which deletes everything, and it should state plainly what will be deleted, so the visitor's consent is informed, not accidental.

Check: When should you use removeItem, and when is clear appropriate?

Section 29 — Keys and naming

Keys are the names under which local storage entries are saved, and choosing them well matters more than it first appears, because keys are shared across all pages of an origin, and a collision, two features using the same key for different data, will cause one feature to overwrite the other's data. A key such as "data" or "list" is dangerously generic: a todo list and a shopping list on the same site, both saving under "list", will corrupt each other, with each load reading the other's data. A key should therefore be specific, descriptive, and namespaced to its feature, such as "todo-tasks", "todo-filter", or "site-theme", so its purpose is clear and its collision risk is low. Prefixing keys with the feature or the application name, such as "kelick-todo-tasks", is a common convention that further reduces collisions, especially on a site with many features or third-party scripts.

Key naming also affects maintenance. When a page's state shape changes in a new version, the old saved data under the old key may no longer match the new shape, and the load routine must handle it, as Section 27 describes, or the key must change to force a fresh start. Changing a key, for example from "tasks-v1" to "tasks-v2", is a clean way to version stored data, because the new version will find no data under the new key and start fresh, while the old data remains harmlessly under the old key, where it can be migrated or ignored. Including a version in the key, or in the stored object itself, as Section 41 discusses, is a deliberate versioning strategy, and it should be decided when the state shape is first designed, not after a breaking change has already corrupted visitors' data. A key is not just a label; it is part of the data's identity, and changing it is a migration decision.

There are also practical habits for keys. Keep a list, in code or in comments, of the keys your site uses and what each holds, so a new feature does not accidentally reuse one. Use consistent casing and separators, such as lowercase words joined by hyphens, so keys are easy to read in developer tools, where a visitor or developer inspecting storage will see them. Avoid keys that contain spaces or special characters, which are legal but harder to work with. And never use a key that includes personal data, such as a username, in a way that leaks information to anyone inspecting storage, because local storage is readable by any script on the origin and by the visitor themselves in developer tools. A well-chosen key is specific, stable, and boring, and that boringness is a virtue: it means the key will not surprise you, collide with another feature, or leak anything it should not.

Check: Why is a generic key such as "list" risky on a site with several features?

Section 30 — Storage limits and quota errors

Local storage is limited in size, typically around five megabytes per origin, though the exact limit varies by browser, by device, and by the browser's settings, and it is shared across all keys for that origin. Five megabytes sounds generous for text, and for preferences, small lists, and drafts it is, but it can be exhausted by large lists, by long text, by images stored as text, or by several features each saving generously. When the limit is exceeded, setItem throws an error, known as a quota exceeded error, and the save fails, leaving the stored copy unchanged while the live state has moved on. If that error is not caught, it will propagate and may break the handler that was saving, potentially preventing the render that follows, so the page will look as if the change did not happen, even though the live state changed.

Handling quota errors has two parts: catching and responding. Catching means wrapping save calls in a try and catch, at least for saves that could be large, so a quota error becomes a handled event rather than a crash. Responding means telling the visitor, calmly, that the data could not be saved because storage is full, and offering a way forward, such as deleting old items, clearing a draft, or exporting the data, rather than silently losing the change. For a todo list, a quota error is unlikely unless the list is enormous, and a simple message plus a suggestion to delete completed tasks may suffice. For a notes application with long texts, a quota error is more plausible, and the application should warn earlier, perhaps when the stored size approaches a threshold, rather than waiting for a hard failure on a save the visitor cares about.

Prevention is better than handling, and it takes several forms. Store only what is needed: a todo list needs text, completion, and id, not a full history of edits or a copy of the rendered HTML. Compress or trim data where possible, such as trimming long notes or limiting a history list to a recent number of entries. Avoid storing large binary data, such as images, in local storage at all, since their text encoding is large and inefficient; a server or a dedicated file store is the right place for those. And be aware that the limit is per origin, so a site with many features sharing storage must budget it across them, rather than each feature assuming it has five megabytes to itself. A quota error is a sign that the page's storage use has outgrown its budget, and the response should be to reduce what is stored, to inform the visitor, and to preserve the live state, so the visitor's work is not lost even if it cannot yet be saved.

Check: What happens when setItem exceeds the storage limit, and how should code handle it?

Section 31 — sessionStorage versus localStorage

Session storage is local storage's sibling, with the same key-value interface, setItem, getItem, removeItem, and clear, but a different lifetime. Data saved in sessionStorage lasts only for the duration of a tab's session: it survives refreshes of that tab, but it is deleted when the tab is closed, and it is not shared with other tabs, even tabs on the same origin. Local storage, by contrast, persists across tab closes and browser restarts, and is shared across all tabs and windows of the same origin. That lifetime difference is the whole distinction, and it determines which store suits which purpose. The interface is deliberately identical, so learning one teaches you the other, and switching between them is a matter of changing the object name, not learning a new API.

The choice between them follows from how long the data should live. Data that should persist across visits, such as a theme preference, a todo list, or a username, belongs in local storage, because the visitor expects it to be there next week, not just until they close the tab. Data that should last only for the current visit, such as a form draft that should not linger after the visitor leaves, a temporary filter, or a multi-step wizard's progress that should reset if the visitor closes the tab and returns later, belongs in session storage, because its lifetime matches the session's. Using session storage for a draft is also a privacy consideration: a visitor on a shared computer who closes the tab will not leave their draft behind for the next person, while a local storage draft would remain. Conversely, using session storage for a todo list would frustrate a visitor who closes the tab expecting their list to persist, and finds it gone.

There are edge cases worth knowing. Session storage survives a refresh, so a form draft in session storage will survive an accidental refresh, which is a useful safety net, but it does not survive a tab close, so it is not a substitute for local storage for long-term data. Session storage is also per-tab, so opening the same site in two tabs gives each tab its own separate session store, and changes in one tab do not appear in the other, unlike local storage, where the storage event, in the next section, can synchronise tabs. That isolation can be a feature, for example when two tabs are filling different forms and should not overwrite each other's drafts, or a limitation, when a setting changed in one tab should apply to the other. Choosing between local and session storage is therefore a decision about lifetime and sharing, and making it deliberately, for each piece of data, is part of designing a page's persistence correctly.

Check: How does sessionStorage differ from localStorage in lifetime and sharing?

Section 32 — Cookies in brief

Cookies are an older browser storage mechanism, and they deserve a brief mention here, mainly to distinguish them from local storage and to explain why they are not the right tool for most front-end state. A cookie is a small piece of data, typically limited to about four kilobytes, that a server or a page can set for an origin, and that the browser then sends back to that origin with every subsequent request, automatically, as a header. That automatic sending is cookies' defining feature, and it is what makes them suitable for their original purpose: remembering a visitor across requests, such as a session identifier that tells the server who is signed in. It is also what makes them unsuitable for large state: every cookie for an origin is sent with every request to that origin, including requests for images and stylesheets, so storing a large list in a cookie would bloat every request and slow the site down.

Cookies also have a different lifetime and control model from local storage. They can have an expiry date, after which the browser deletes them, or they can be session cookies, deleted when the browser closes. They can be marked with flags that local storage does not have, such as HttpOnly, which prevents JavaScript from reading them at all, a security feature for sensitive session identifiers, and Secure, which restricts them to HTTPS. Those flags are set by the server, not by front-end code, and they reflect cookies' role as a server-oriented mechanism, managed jointly by server and browser, rather than a front-end store managed by page code alone. Front-end JavaScript can read and write cookies through the document.cookie string, but that interface is awkward, a single string containing all cookies for the origin, and it is rarely the right choice for application state in a modern page.

For the purposes of this book, the rule is simple: use local storage for device-local, front-end state, such as preferences, lists, and drafts, and leave cookies to the server for session management and for data the server needs to receive on every request. Do not store large state in cookies, do not store sensitive data in cookies readable by JavaScript, and do not use cookies as a general state store when local storage is available and better suited. Understanding cookies at this level, what they are for, why they are sent automatically, and why that makes them unsuitable for large state, is enough to avoid misusing them, and to recognise when a server-side feature, such as sign-in, is using them correctly for its own purpose.

Cookies also have a size limit per cookie and per origin that is far smaller than local storage's, which is another reason they are unsuitable for application state. A cookie that exceeds its limit may be silently dropped by the browser, or may truncate the data, leading to a session or preference that mysteriously stops working. Local storage's larger limit, and its not being sent with every request, make it the better tool for the data a front-end page needs to keep, while cookies remain the tool for small, server-needed identifiers.

Check: Why are cookies a poor place to store a large todo list?

Section 33 — When storage is unavailable

Local storage is usually available, but not always, and code that assumes it is will break in the cases where it is not. Private browsing modes in some browsers limit or disable local storage, or make it appear available but discard its contents when the session ends. Browser settings can disable storage for privacy. Storage can be full, as Section 30 discusses, or it can be blocked by a policy in a managed device or a school computer. In some older or unusual environments, accessing localStorage itself can throw an error, rather than merely returning null or failing on save. A page that crashes on load because storage is unavailable has turned a persistence feature, which should be a convenience, into a barrier that prevents the page from working at all, and that is the opposite of what persistence should do.

The correct approach is to treat storage as an enhancement, not a requirement. The page's core functionality, displaying and editing a list, choosing a theme, filling a form, must work without storage, using live state variables alone, and storage should add persistence on top of that working core. Implementing that means wrapping storage access in try and catch, both on load and on save, so a storage error becomes a silent fallback to live-only state, rather than a crash. On load, a storage failure means starting with the default state, as if nothing was saved. On save, a storage failure means the live state continues to work, but will not persist, and, where appropriate, a gentle message can inform the visitor that their changes will not be saved on this device, without blocking them from continuing. That graceful degradation is the hallmark of robust front-end code: features degrade, but the page does not break.

Testing for unavailable storage is part of building this robustness. Private browsing mode is the easiest way to test it, and it is worth opening your page in a private window and confirming that it still loads, still lets the visitor add and toggle items, and still renders correctly, even if those changes do not persist after the window closes. That test catches code that crashes on storage access, code that assumes a saved value exists, and code that saves in a way that throws when storage is full or disabled. A page that passes that test has storage as a true enhancement: it improves the experience when available, and it does not harm it when it is not. That is the standard to aim for, because visitors' environments vary, and a page that only works in ideal conditions is a page that will fail for someone.

A page should also handle the case where storage is available for reading but not for writing, such as a full store, by catching save errors separately from load errors. A load failure means starting fresh, while a save failure means continuing with live state but warning the visitor that changes will not persist. Treating those two failures differently, rather than with a single 'storage unavailable' message, gives the visitor accurate information about what is lost and what still works, which is more helpful than a generic warning.

Check: How should a page behave when localStorage is unavailable?

Section 34 — The storage event across tabs

When a page is open in two tabs at once, and one tab changes local storage, the other tab needs to know, or it will show stale data. The browser provides that notification through the storage event, which fires on all other tabs and windows of the same origin when a local storage entry is set, removed, or cleared, but not on the tab that made the change itself. The event object carries useful information: its key property says which key changed, its oldValue and newValue properties say what the value was before and after, and its url says which page made the change. A tab listening for this event can therefore learn, in real time, that another tab changed the shared state, and it can react, typically by reloading its own state from storage and re-rendering, so both tabs show the same data.

Implementing cross-tab synchronisation with the storage event is straightforward in principle. Each tab listens for storage on the window, checks whether the changed key is one it cares about, and if so, loads the new value from storage, updates its live state, and renders. That keeps the tabs in sync without polling, which would waste resources by repeatedly checking storage for changes that have not happened. There are details to handle: the event does not fire in the tab that made the change, so that tab must update its own display through its normal save-and-render sequence, not by waiting for an event that will never come. The event's newValue is null when the key was removed, so the handler must treat null as a reset to default, not as an error. And rapid changes in one tab will fire several events in the other, so the handler should be efficient, reloading and rendering each time, or debouncing rapid events if rendering is expensive.

Cross-tab sync is not always desired, and deciding whether to implement it is part of designing a feature. For a shared list, such as a todo list open in two tabs, syncing is clearly good: a task added in one tab should appear in the other, without a refresh. For a form draft, syncing might be confusing, if two tabs are editing different drafts and each overwrites the other, though for a single draft shared across tabs, syncing keeps them consistent. For a theme preference, syncing is pleasant: changing the theme in one tab updates the other tab's appearance immediately. The storage event makes all of these possible, and the decision is a product decision, not a technical one: does this piece of state belong to the visitor across all their tabs, or to a single tab's session? If the former, listen for storage events and sync; if the latter, session storage, which is per-tab, may be the better store in the first place.

Check: Which tabs receive the storage event when one tab changes localStorage?

Section 35 — Syncing tabs without loops

Synchronising tabs with the storage event introduces a risk: a loop, where one tab's change triggers another tab's change, which triggers the first tab's change again, endlessly. That loop happens if the event handler, on receiving a change, writes back to storage, for example by saving a normalised or reformatted version of the new value, which fires another storage event in the first tab, which writes back again, and so on. The loop wastes resources, flickers the display, and can, in a bad case, run indefinitely. Preventing it requires a discipline: an event handler should update live state and render, but it should not write back to storage unless the value actually needs changing, and it should never write back the same value it just received, because that write, even if the value is identical, may still fire an event in other tabs, depending on the browser's implementation.

A related risk is conflict, where two tabs change the same data at the same time, and the last write wins, discarding the other tab's change. If two tabs both add a task to a list, and each loads the list, adds its task, and saves the whole list, the second save will overwrite the first, losing the first tab's task, because each tab saved a list that did not include the other's addition. That lost-update problem is a classic issue in shared state, and local storage's simple key-value model has no built-in solution for it, such as merging or locking. For a personal todo list used in two tabs, the risk is low and the consequence is minor, a lost task that the visitor can re-add, but for shared or critical data it would be unacceptable, and a server with proper concurrency control would be needed. For this book's purposes, the lesson is to be aware of the risk, to reload from storage before saving when a change is made, reducing the window for a lost update, and to accept that local storage sync is best-effort, not guaranteed, for simultaneous edits.

A practical sync handler therefore follows a careful shape: on a storage event for a key it cares about, it loads the new value, validates it, assigns it to live state without writing it back, and renders. It does not save, it does not normalise and re-save, and it does not trigger any other storage write as a side effect. That shape prevents loops, because the handler is read-only with respect to storage, and it keeps the tabs in sync, because each tab's display follows its live state, which follows the latest stored value. If a tab needs to make its own change, it does so through its normal state function, which saves and renders, and that save will fire events in the other tabs, which will update read-only, closing the loop without creating a cycle. Read on event, write on user action, never write on event, is the rule that keeps multi-tab sync stable.

Check: How can a storage event handler cause an infinite loop, and how do you prevent it?

Section 36 — Theme persistence in practice

Theme persistence is the simplest complete example of local storage in action, and it is worth studying because it contains the whole persistence loop in miniature. The state is a single string, "light" or "dark", held in a variable and reflected in the page by a class on the body or on the document element, which CSS uses to choose colours. The toggle button changes the state, saves it under a key such as "theme", and applies it, by setting the class, in one handler. The load routine, run before the first paint if possible, reads the saved theme, or uses a default if none was saved, and applies it, so the visitor sees their preferred theme immediately, without a flash of the default theme followed by a switch. That flash, known as a flash of unstyled or wrongly themed content, is the main polish issue in theme persistence, and avoiding it requires loading and applying the theme as early as possible, ideally in a small script in the head, before the page's main content renders.

Applying a theme from state, rather than toggling classes directly, follows the state-first pattern of this chapter. The state variable holds the theme, a function applies it by setting the class and updating the toggle button's label or appearance, and the toggle handler changes the state, saves it, and calls the apply function. That structure means the theme can be changed from anywhere, by changing the state and applying it, and it will always be consistent, because the class is derived from the state, not stored separately. It also means the storage event, from Section 34, can sync the theme across tabs: a theme change in one tab fires a storage event in the other, whose handler loads the new theme, updates its state, and applies it, so both tabs change together. That cross-tab sync is a pleasant detail that visitors notice, and it comes almost for free once the theme is held as state and saved on change.

Theme persistence also illustrates the importance of defaults and of respecting the visitor's system preference. A first-time visitor has no saved theme, so the page should choose a sensible default, which may be light, or may be the system's preference, read from the browser's media query for dark mode, if the site offers that choice. That system preference is a starting point, not a stored value, and it should only be used when no saved theme exists, because once the visitor has chosen explicitly, their choice should override the system's. The load routine therefore has three steps: check for a saved theme, use it if present; otherwise check the system preference, use it if the site supports it; otherwise use the site's default. That hierarchy, saved choice first, system second, default last, respects the visitor's explicit decision above all, while still giving a new visitor a theme that matches their system. A single string, saved and loaded correctly, can thus teach the whole persistence pattern, from state to storage to cross-tab sync to defaults.

Check: Why should a saved theme be applied before the page first paints, if possible?

Section 37 — Form draft persistence

A form draft is the text a visitor has typed into a form but not yet submitted, and persisting it protects that work against accidental loss. A visitor writing a long message, a review, or an application may refresh the page by mistake, close the tab, or lose their connection, and without draft persistence their work vanishes, forcing them to start again. That loss is frustrating enough to make some visitors abandon the task entirely, especially for long forms. Saving the draft to local storage as the visitor types, and restoring it when they return to the form, turns that loss into a minor interruption: the visitor returns, finds their draft intact, and continues where they left off. That protection is a kindness that costs little to implement, because the draft is simply the form's state, saved on input and loaded on start, in the patterns already covered.

Implementing draft persistence requires decisions about what to save, when to save, and when to clear. What to save is the form's fields, as an object keyed by field name, so each field's draft value can be restored individually. When to save is on input, but debounced, as Section 38 explains, so a save does not happen on every keystroke, but after a pause, balancing protection against performance. When to clear is on successful submission, because the draft has served its purpose and should not reappear the next time the visitor opens a fresh form; clearing on submit, with removeItem for the draft key, ensures a new visit starts with an empty form, not with the previous submission's text. A related decision is whether to restore a draft automatically or to offer it: automatic restoration is simpler and suits a personal form, while offering, with a message such as "You have an unsaved draft. Restore it?", suits a shared computer, where an automatic restoration might reveal a previous visitor's draft to the next person.

Draft persistence also raises a privacy point. A draft may contain personal or sensitive text, and saving it to local storage means it remains on the device after the visitor leaves, accessible to anyone who uses the same browser profile and visits the same site. For a personal device, that is usually acceptable, and it is the point of the feature. For a shared device, or for a form asking for sensitive information, draft persistence may be inappropriate, or it may belong in session storage, which is cleared when the tab closes, rather than local storage. The decision should follow the data's sensitivity and the device's sharing: personal, non-sensitive drafts on a personal device suit local storage; sensitive drafts, or drafts on a shared device, suit session storage or no persistence at all. As with all persistence, the feature's benefit, protecting work, must be weighed against its cost, leaving data behind, and the choice should be deliberate, not automatic.

Check: When should a saved form draft be cleared?

Section 38 — Autosave with debouncing

Autosave means saving automatically, without the visitor pressing a save button, as they work, and debouncing is the technique that makes autosave practical. Saving on every keystroke, as Section 24's save-on-change habit might suggest for small state, becomes wasteful for a long draft, because each keystroke would stringify and store the whole draft, blocking the page briefly each time and wearing out the storage with frequent writes. Debouncing solves this by waiting for a pause in typing, commonly a few hundred milliseconds to a second, before saving, so a burst of typing produces one save, for the draft as it stands after the pause, rather than a save per character. The implementation is a timer, cleared and restarted on each input event, whose firing triggers the save, the same pattern as the debounced search in Chapter 22, applied to a different purpose.

Autosave also needs a visible indicator, because a save that happens silently leaves the visitor unsure whether their work is protected. A small status text, such as "Draft saved" with a time, or "Saving..." while the save is in progress, reassures the visitor that the system is looking after their work, and it makes the feature discoverable: a visitor who sees "Draft saved" learns that they can safely leave and return. The indicator should update on the debounced save, not on every keystroke, or it will flicker distractingly, and it should be subtle, a small text near the form, not a modal dialog or an alert, because autosave is a background protection, not an interruption. A related detail is to save on page hide or before unload as well, as a final safety net, though that save should not be relied on alone, as Section 22 notes, because unload handlers are not guaranteed to run.

There is a balance in choosing the debounce delay for autosave. Too short, and the savings are frequent and wasteful, approaching save-on-keystroke. Too long, and a visitor who types and immediately closes the tab may lose their last seconds of work, because the debounce timer never fired before the tab closed. A delay of around one second is a common compromise for drafts, long enough to batch a burst of typing, short enough that a brief pause, such as the visitor stopping to think, triggers a save. Combining debounced autosave with a save on blur, when the visitor leaves a field, and a save on submit, gives good coverage: the draft is saved after pauses, when a field is finished, and when the form is submitted, with the submit save followed by a clear, as Section 37 describes. Autosave done well is invisible when it works and invaluable when it is needed, which is the best kind of protection.

Check: Why should autosave be debounced instead of saving on every keystroke?

Section 39 — What not to store

Not everything should be stored in local storage, and knowing what to exclude is as important as knowing what to include. Passwords should never be stored in local storage, in any form, because any script running on the page can read local storage, and a cross-site scripting vulnerability, from Chapter 22, would expose every stored password to an attacker. The same applies to authentication tokens with broad powers, to payment details, to personal identification numbers, and to any secret that would harm the visitor if stolen. Those values belong, if they must be stored in a browser at all, in mechanisms designed for them, such as secure, HttpOnly cookies managed by the server, which JavaScript cannot read, or in memory for the duration of a session, with the visitor signing in again afterwards. Storing them in local storage for convenience trades a small usability gain for a serious security risk, and it is a trade that should never be made.

Beyond secrets, there are categories of data that should not be stored because they are unsuitable for device-local storage. Large data, such as images, videos, or long histories, will exhaust the quota and slow the page, and belongs on a server or in a dedicated file store. Shared data, such as a team's tasks or a public feed, will diverge across devices if stored locally, and belongs on a server as the single source of truth, with local storage at most as a cache. Sensitive personal data, such as health information, financial details, or private messages, should not be stored locally without careful consideration of who else uses the device, because local storage persists after the visitor leaves, and a shared device's next user could read it. For such data, the question to ask is not "Can I store this?" but "Should this remain on this device after the visitor leaves, and who else might read it?" If the answer is uncertain, the safe choice is not to store it, or to store it only in session storage, which is cleared when the tab closes.

A practical rule for deciding what to store is to ask three questions. Is this data personal to the visitor and to this device? Is it small enough for the quota? And would its exposure, to another user of this device or to a malicious script, cause harm? If the answers are yes, yes, and no, local storage is a good fit. If any answer differs, consider session storage, a server, or no storage at all. That rule keeps local storage for its strengths, preferences, drafts, and small personal lists, and keeps it away from its weaknesses, secrets, large data, and shared data. A developer who stores everything locally, because it is easy, will eventually store something that should not be there, and the harm from that one mistake can outweigh the convenience of all the correct stores combined.

Check: Why should passwords never be stored in localStorage?

Section 40 — State and privacy

State and privacy are connected, because state often contains information about the visitor, and how that state is stored determines who can see it. Local storage data is stored on the visitor's device, under the page's origin, and it is readable by any script running on that origin, and by anyone with access to the device who opens developer tools or the browser's storage view. It is not sent to the server automatically, unlike cookies, which is a privacy benefit, because a preference or a draft stored locally does not become server data unless your code sends it. But it is also not encrypted, and it is not protected from other scripts on the same origin, so a third-party script included on the page, such as an advertisement or an analytics script, could read the same storage, if it runs on the same origin. That shared access is a reason to be selective about what is stored, and to avoid storing sensitive personal data locally, as the previous section argues.

Privacy also concerns what a page reveals through its state, even without storage. A page that shows a visitor's previous searches, their recent items, or their personal lists on arrival, without them signing in, is revealing information that a previous user of a shared device might see. A todo list that loads automatically on a shared computer will show the previous user's tasks to the next user, which may be embarrassing or revealing, depending on the tasks. Designing for privacy on shared devices means considering whether a feature should load automatically, or whether it should require an explicit action, such as a "show my saved list" button, or whether its data should live in session storage, which is cleared when the tab closes, rather than local storage. Those design choices are not purely technical; they are judgements about the visitor's context, and they should be made with shared devices in mind, not only with personal devices.

There is also a transparency obligation. If a page stores data locally, it should be clear, in its settings or in a brief note, what is stored and how to delete it, so a visitor who wants their data gone can make it gone. A "clear my saved data" button, which removes the relevant keys and resets the live state, gives the visitor control, and it is a simple feature to implement with removeItem and a render. Respecting that control, and not storing data the visitor has asked to delete, is part of treating persisted state as the visitor's data, held on their behalf, rather than as the page's property. State is memory, and memory about a person deserves the same care as any personal information: store only what is needed, protect it from unintended readers, and give the person a way to erase it.

Check: Who can read data stored in localStorage for a site?

Section 41 — Versioning stored data

Stored data outlives the code that saved it. A visitor may save a todo list with one version of your page, and return after you have updated the page to a new version whose state shape is different: a task object that once had only text and completion may now also have a priority, a due date, or a category. The old saved data, lacking those new fields, will be loaded by the new code, which may assume the fields exist and crash, or may display tasks without priorities, confusingly. Versioning stored data solves this by marking each saved copy with the version of the shape it uses, so the loading code can recognise an old shape and migrate it, transforming it into the new shape, or discard it safely if migration is not possible. Without versioning, every state-shape change is a potential breaking change for returning visitors, which is a high price for a small improvement.

There are two common ways to version. The first is to include a version number in the stored object itself, such as saving { version: 2, tasks: [...] } instead of just the tasks array, so the load routine can check the version and branch accordingly: version 1 data, lacking priorities, gets a default priority added to each task; version 2 data is used as it is; unknown versions fall back to a default. The second is to include the version in the key, such as "tasks-v2", so a new version looks under a new key, finds nothing, and starts fresh, while the old data remains under the old key, where a migration routine can read it once, convert it, and save it under the new key. Both approaches work, and the first is generally better, because it keeps the data under one key and makes migration explicit, while the second is simpler to implement for a clean break where old data is not worth migrating.

Migration itself should be careful and conservative. It should add missing fields with sensible defaults, rather than discarding items that lack them, because discarding a visitor's tasks to fix a shape mismatch is a data loss disguised as an upgrade. It should preserve fields it does not recognise, rather than dropping them, in case a future version needs them, or in case the data came from a newer version than the code expects, which can happen if a visitor uses two versions of a site in different tabs. And it should be tested with old saved data, by saving with the old version, loading with the new version, and confirming that the tasks survive with their new defaults, a test that is easy to run in developer tools by editing the stored value. Versioning is a small upfront cost, a version field and a migration branch, that prevents a large downstream cost, returning visitors whose data breaks on an upgrade, and it is a habit worth adopting from the first version of any persisted state.

Check: Why should saved data carry a version number?

Section 42 — Separating state, rendering and storage

A well-built stateful page has three layers, each with a clear job, and keeping them separate is what makes the page maintainable. The state layer holds the data and the functions that change it, such as adding, toggling, and filtering. It knows nothing about HTML and nothing about storage; it simply maintains the current situation and the rules for changing it. The rendering layer takes state and produces display, reading the state and building HTML, but never changing the state and never reading storage directly. The storage layer takes state and saves it, and loads saved data into state, but never builds HTML and never changes state except through the loading step at startup. Each layer depends on the state, but the layers do not depend on each other directly: rendering does not call storage, storage does not call rendering, and state functions call both, in the sequence update, save, render.

That separation pays off in several ways. Testing becomes easier, because each layer can be tested alone: state functions can be tested by calling them and checking the state, without needing a page; rendering can be tested by giving it a known state and inspecting its output, without needing storage; storage can be tested by saving and loading a known object, without needing a display. Changing one layer becomes safer, because its interface to the others is narrow: restyling the rendering does not affect state or storage, changing the storage key does not affect rendering, and adding a new state field does not require rewriting the storage layer, only ensuring it is included in the saved object. And debugging becomes a process of elimination, as in Section 2: if the display is wrong, check whether the state is wrong or the rendering is wrong; if the saved data is wrong, check whether the state was wrong when saved or the storage conversion was wrong.

In small pages, the three layers may live in a single script file, separated by comments and by function grouping, rather than in separate files, and that is fine. Separation is a matter of responsibilities, not of file count. What matters is that a rendering function does not reach into local storage to fetch data, that a storage function does not manipulate the DOM, and that a state function does not build HTML strings. When those boundaries are respected, the code reads as a story with three characters, each doing one job, and a new developer, or the original developer returning after a break, can understand the page by understanding each layer in turn. When the boundaries are blurred, with storage reads inside rendering and DOM updates inside state changes, the code becomes a tangle where a change in one place ripples unpredictably into others, and that tangle is the beginning of a page that is hard to fix and harder to extend.

Check: What are the three layers of a stateful page, and what does each one do?

Section 43 — UI state: filters, search and pagination

Not all state is data about things, such as tasks or products. Some state is about the interface itself: which filter is selected, what search term is typed, which page is shown, whether a panel is open, or how a list is sorted. That UI state is just as much state as the data, and it deserves the same treatment: a named variable, changes through functions, and rendering derived from it. A filter setting, for example, is a string variable, changed when a filter button is clicked, and the displayed list is derived from the data plus the filter, as Section 12 explains. A search term is a string variable, changed on input, and the displayed list is derived from the data plus the term. A page number is a number variable, changed by pagination buttons, and the displayed list is derived from the data plus the page number and page size. In each case, the UI state does not change the underlying data; it changes which part of the data is shown, and how.

UI state has a question that data state does not always raise: should it persist? A filter or a search term is often session-specific: a visitor who searched for "cats" yesterday may not want that search still active when they return today, and persisting it could confuse them. A page number is almost never worth persisting, because the visitor's place in a list is rarely meaningful across visits, and the list itself may have changed. A theme, by contrast, is worth persisting, because it is a preference that should hold across visits. A panel's open or closed state is usually session-specific, unless it is a preference such as "always show advanced options". The decision for each piece of UI state should be made deliberately: persist preferences, do not persist transient navigation, and when in doubt, do not persist, because a fresh, predictable starting point is less confusing than a restored UI state the visitor has forgotten they set.

UI state also interacts with other state in ways that need care. Changing a filter should reset the page number to the first page, because the filtered list has fewer pages than the unfiltered list, and staying on page five of a list that now has only two pages would show an empty page. Changing a search term should similarly reset pagination, and changing the data itself, such as adding an item, may affect the page count and the current page's contents. Those interactions are handled naturally if the rendering function derives the displayed page from the current data, filter, search, and page number together, and if state functions that change one piece also reset dependent pieces where needed, such as setting the page to one when the filter changes. UI state, treated as first-class state with its own variables and its own interactions, is what makes an interface feel coherent, rather than a collection of independent controls that occasionally contradict each other.

Check: Should a search term or a page number usually persist across visits? Why?

Section 44 — The URL as a place for state

The URL can also hold state, in its path and its query string, and it has a unique advantage: it can be shared, bookmarked, and revisited. A search term in a query string, such as ?q=cats, means a visitor can copy the address and send it to someone else, who will open the same search. A filter and a page number in a query string, such as ?category=books&page=2, mean a bookmark returns to the same filtered page, not to the site's homepage. That shareability is something local storage cannot offer, because local storage is private to a device, and it is something live variables cannot offer, because they vanish on refresh. For state that defines what the visitor is looking at, such as a search, a filter, or a page, the URL is therefore a natural home, and many sites use it for exactly that purpose.

Putting state in the URL has trade-offs. The URL is visible, so it should not hold sensitive data, such as a password, a token, or personal information, because it appears in history, in logs, and in shared links. It is also limited in length and in format, so it suits short, simple values, such as a search term or a page number, not a whole todo list. Updating the URL without reloading the page is possible with the browser's history API, which lets JavaScript change the address bar and add a history entry, so the back button works correctly, but that API adds complexity, and it must be used carefully to keep the URL and the live state in sync: changing the filter should update the URL, and navigating back should restore the previous filter, which requires listening for history changes and updating the state accordingly. That two-way sync, state to URL and URL to state, is the work that makes URL state reliable.

For this book's plain JavaScript projects, URL state is best used sparingly, for the cases where shareability matters most, such as a search page whose results should be linkable. For other UI state, such as a theme or a todo filter, local storage or live variables are simpler and sufficient. The lesson is not that all state belongs in the URL, but that the URL is one more place state can live, with its own strengths, shareability and bookmarkability, and its own limits, visibility and size. Choosing a home for each piece of state, live variable for transient state, local storage for device preferences, URL for shareable views, and server for shared data, is a design decision that should be made per piece, not by default for the whole page. A page that places each piece where its properties fit will feel natural to use, because its state will behave the way visitors expect for that kind of information.

Check: What is the main advantage of putting a search term in the URL instead of only in a variable?

Section 45 — A simple undo

Undo is the ability to reverse a recent change, and even a simple version of it can save a visitor from a costly mistake, such as deleting the wrong task. The simplest undo implementation keeps a history of previous states, as a list of snapshots, each snapshot being a copy of the state before a change. When a change happens, the previous state is pushed onto the history list, before the new state replaces it. When the visitor clicks undo, the most recent snapshot is popped from the history and becomes the current state, which is then saved and rendered, restoring the page to its previous situation. That snapshot approach is easy to understand and to implement for small states, because it reuses the same save, load, and render machinery as persistence, with the history list held in a variable rather than in storage.

Snapshot undo has limits that shape its use. Each snapshot is a copy of the whole state, so a long history of a large state will use a lot of memory, and a practical implementation limits the history's length, for example to the last ten or twenty changes, discarding older snapshots. Copying the state must also be a true copy, not a reference to the same object, or changing the current state will also change the snapshot, defeating the purpose. For a list of plain objects, a copy can be made with JSON stringify and parse, which creates a fresh object tree, though that technique has the same type limits as storage, discussed in Section 24. A single-step undo, keeping only the most recent previous state, avoids most of those complications, and it covers the most common case, a mistaken deletion or toggle, where the visitor wants to reverse exactly one action. A "restore deleted task" message that appears for a few seconds after a deletion, offering one-click restoration, is a friendly form of single-step undo that many interfaces use.

Undo also interacts with persistence: when an undo restores a previous state, that restored state should be saved, so a refresh after an undo does not revert to the pre-undo state, and it should be rendered, so the display matches. The undo history itself is usually not persisted across visits, because it is session-specific, and a visitor returning tomorrow does not expect to undo yesterday's changes. That session scope keeps the history small and its meaning clear: undo reverses recent actions in this visit, not actions from previous visits. Even a simple undo, limited to one step and to the current session, transforms deletion from a frightening, irreversible act into a reversible one, and that transformation makes visitors more confident to use a page, because they know a mistake can be undone. Confidence, in turn, makes a tool feel safe, which is a quality worth designing for.

Check: How does keeping a snapshot of the previous state make undo possible?

Section 46 — Testing state logic without the page

State logic, the functions that add, remove, toggle, and filter, can be tested without a page, without a browser display, and without storage, by calling the functions with known inputs and checking the resulting state. That separation is possible because state functions, as Section 17 recommends, take inputs and change state, without depending on the DOM or on storage directly. A test for adding a task might start with an empty list, call the add function with a sample text, and check that the list now contains one task with that text and with done set to false. A test for toggling might start with a known task, call the toggle function with its id, and check that its done flag flipped. Those tests are small, fast, and precise, and they catch logic errors, such as adding a duplicate, toggling the wrong item, or filtering incorrectly, without the distraction of display or persistence.

Testing without the page requires that state functions are written to be testable, which means they should not read from inputs or write to the DOM inside the function itself. Instead, they should take values as arguments and return or update state, leaving input reading to event handlers and display updating to rendering functions. That division, handlers read, state functions change, rendering displays, is the same separation Section 42 advocates, and it is what makes testing possible: a state function that reads an input's value directly cannot be tested without setting up that input, while a state function that takes a text argument can be tested with any string. Similarly, a state function that saves to storage as a side effect can still be tested, but its tests must handle storage, while a state function that only changes state, with saving done by its caller, is cleaner to test. Those are design choices that trade a little extra wiring in the caller for a large gain in testability.

Even without a formal testing framework, manual testing of state logic is valuable. Opening the console, calling state functions directly with sample values, and inspecting the state variable afterwards, is a quick way to verify that the logic works, and it is a habit worth building during development. That console testing catches errors early, before they are hidden behind event handling and rendering, and it builds confidence in the logic that the rest of the page depends on. Automated tests, in a later chapter, will formalise that habit, but the habit itself, testing logic by calling functions and checking state, is available immediately, and it is one of the benefits of separating state logic from display and storage. A page whose state logic is tested is a page whose behaviour can be trusted, even when its display is being restyled or its storage is being changed.

Check: Why can state functions be tested without a browser page if they are written well?

Section 47 — Common state bugs

Several state bugs recur in front-end code, and recognising them by name helps you spot them quickly. The first is stale state, where the display shows an old state because rendering was forgotten after a change, or because a handler read a variable captured before the change. The symptom is a click that appears to do nothing, or a change that appears one step late, and the fix is to ensure every state change is followed by a render, in the update-then-render order of Section 7. The second is duplicated state, where the same fact lives in two places and they disagree, as Section 3 warns, such as a count stored separately from the list it counts. The symptom is a number that does not match the list, and the fix is to derive the count from the list, not to store it. The third is direct DOM state, where a change is made to the display without changing the state, as Section 5 warns, and the next render overwrites it. The symptom is a change that disappears on the next interaction, and the fix is to change state first and let rendering follow.

A fourth common bug is identity confusion, where an operation acts on the wrong item because it used a position instead of an id, as Sections 10 and 12 warn. The symptom is deleting or toggling the wrong task after filtering or reordering, and the fix is to use stable ids for all operations. A fifth is a missing save, where a change path updates state and renders but forgets to save, so the change looks correct until a refresh reverts it. The symptom is data that vanishes on reload, and the fix is to centralise changes in state functions that always save, or to audit every change path for its save call. A sixth is a stale save, where the stored copy is loaded but never updated, or where a load overwrites a newer live state, such as a storage event handler that writes back, as Section 35 warns. The symptom is data that reverts unexpectedly, and the fix is to keep the save and load order clear: load once at start, save on change, and never write on a storage event.

A seventh bug is mutation of a snapshot, where an undo history or a saved copy shares references with the live state, so changing the live state also changes the snapshot, defeating undo or corrupting the saved copy. The fix is to copy state when snapshotting or saving, using a true copy rather than a reference, as Section 45 notes. All of these bugs share a root cause: a breakdown in the discipline that state is single, that changes go through functions, that display follows state, and that storage mirrors state. Keeping that discipline, and checking new code against it, catches most state bugs before they reach a visitor, and when a bug does appear, naming it from this list points quickly to its fix.

Check: Name three common state bugs and the fix for each one.

Section 48 — Performance and re-rendering

Re-rendering a list from scratch on every change is simple, but it has a performance cost that grows with the list and with the frequency of changes. Rebuilding a thousand elements for a single toggle, or re-rendering on every keystroke for a search, can make a page feel sluggish, because the browser must create elements, compute styles, and lay them out, repeatedly, for work that changes only a small part of the display. For the lists in this book, with tens or hundreds of items and changes on clicks rather than keystrokes, that cost is negligible, and simplicity should win. But as a list grows, or as changes become frequent, performance becomes a design consideration, and several techniques can help without abandoning the state-first approach.

The first technique is targeted updating, from Section 18: update only the element for the changed item, rather than rebuilding the whole list, so the work is proportional to the change. The second is debouncing frequent changes, from Section 38 and Chapter 22: wait for a pause in typing before re-rendering a search result, so a burst of keystrokes produces one render rather than many. The third is avoiding unnecessary renders: if a state change does not affect the display, such as changing a hidden field, or if a render is requested twice in a row with the same state, skipping the second render saves work. A simple guard, comparing the new state with the previous state, or tracking whether a render is already pending, can implement that skipping, though it adds complexity and should only be used when rendering is actually measured to be slow. The fourth is pagination or virtualisation for very large lists: showing only a page, or only the visible window, of a list, rather than rendering all of it, so the DOM stays small regardless of the list's size. Virtualisation, rendering only the items currently visible in a scrollable area, is an advanced technique used by large feeds, and it is beyond this book's plain JavaScript scope, but knowing it exists helps you recognise when a list has outgrown simple re-rendering.

The guiding principle for performance is to measure before optimising. A page that feels fast does not need targeted updating, debouncing, or virtualisation, and adding them prematurely will complicate the code without benefit. A page that feels slow should be profiled, to find whether the bottleneck is rendering, saving, or network, before any technique is applied, because optimising the wrong part wastes effort. State-first design, with rendering derived from state, actually helps performance work, because it makes the rendering step explicit and measurable, and it allows techniques like debouncing and targeted updating to be applied at that step, without changing the state logic. Performance is therefore not a reason to abandon state-first design; it is a reason to apply it carefully, and to optimise the rendering step when, and only when, it is measured to be the problem.

Check: When should you optimise re-rendering, and when is simple re-rendering enough?

Section 49 — Putting it together: a notes app

A notes app brings together everything in this chapter into one feature, and it is a fitting practical example, because it uses state, rendering, and persistence in a complete loop. The app has a list of notes, each with a title, a body, and an id, held as an array in state. It has a selected note, held as an id in state, whose body is shown in an editor. It has a search term, held as UI state, which filters the list for display. It has a theme, held as a preference and persisted. And it has persistence for the notes themselves, saved as JSON in local storage on every change, loaded on start, with validation and a fallback to an empty list on corrupt data. Each piece of state has a home, each change goes through a function, each function saves and renders, and the display is always derived from the current state. That is the architecture of the app, and it is built entirely from this chapter's techniques.

The app's interactions show how those techniques combine in use. Creating a note adds a new object to the state, saves, renders, and selects the new note, so the editor shows it immediately. Editing a note updates its body in state, debounced autosave writes it to storage, and the list's title updates on render if the title changed. Deleting a note removes it by id, saves, renders, and selects another note if the deleted one was selected, so the editor never shows a deleted note. Searching filters the list for display without changing the stored notes, and clearing the search restores the full list. Changing the theme saves the preference and applies it, and opening the app in another tab syncs the notes and the theme through storage events, read-only in the handler, so the tabs stay consistent without loops. Each interaction is a short sequence of state, save, and render, and the app's reliability comes from following that sequence consistently, not from any single clever trick.

Building such an app end to end, even in a simple form, is the practical task below, and it is worth doing slowly, adding one feature at a time and testing each addition, including refresh tests for persistence and multi-tab tests for sync, rather than writing the whole app at once. Start with the list and its rendering, add persistence and test it with refreshes, add selection and editing, add search, add theme, and add cross-tab sync last. Each addition is a technique from this chapter, and each one you test as you add it becomes a skill you can reuse. A notes app that survives refreshes, syncs across tabs, and never shows a state it does not hold is a page that has mastered state, and that mastery is the foundation for every larger front-end application you will build.

Check: List the pieces of state a notes app holds, and which ones should persist.

Section 50 — State, storage and the page lifecycle

A page has a lifecycle, and state moves through it in a predictable sequence that summarises this whole chapter. The page loads, and its initialisation code loads any saved state from storage, validating it and falling back to defaults for missing or corrupt data, before the first render, so the first display is the saved state. The visitor interacts, and each interaction calls a state function, which validates its input, updates the state, saves the new state to storage, and renders the new display, in that order, so live state, stored state, and display stay in agreement. The visitor may open another tab, and storage events keep the tabs in sync, with handlers that read and render but never write back, so the tabs agree without loops. The visitor may close the page, and the stored state remains, ready for the next visit, because it was saved on every change, not only on unload. That sequence, load, interact with save and render, sync, and persist across visits, is the complete lifecycle of state in a browser page.

Understanding that lifecycle helps you place each technique in its proper moment. Loading belongs at startup, before rendering. Saving belongs on change, not on unload. Rendering belongs after every state change, not before. Syncing belongs in a storage event handler, read-only. Undo belongs as a snapshot before a change, restored on request. Versioning belongs in the saved format, checked on load. Privacy belongs in the decision about what to save at all, made before any save call. Each technique has a moment in the lifecycle where it fits, and placing it there, rather than elsewhere, is what makes the page coherent. A technique placed in the wrong moment, such as saving only on unload, or rendering before updating, will produce a bug that is hard to trace, because the code looks correct in isolation and only fails in the sequence of a real visit.

The broader lesson of this chapter is that state is not an afterthought or a byproduct of display; it is the page's memory, its truth, and its continuity across interactions and visits. A page that names its state, keeps it in one place, changes it through functions, renders from it, saves it on change, loads it before rendering, and syncs it carefully across tabs, is a page that visitors can trust with their work, because their work will be there when they return, will look as they left it, and will not vanish on a refresh or a mistaken click. That trust is the real product of good state management, and it is built from the small disciplines in this chapter, repeated consistently, rather than from any single large technique. Keep state single, change it deliberately, render from it, and save it faithfully, and the rest of front-end development, from APIs to frameworks, will build on a solid foundation.

Check: Describe the lifecycle of state from page load to the next visit, in order.

Practical task

Build a notes app with a list of notes, each with a title, body and id, an editor for the selected note, a search box that filters the list, and a theme toggle. Hold all state in one object, change it only through named functions, render the list and editor from state, save the notes and theme to localStorage as JSON on every change, load and validate them before the first render, handle missing or corrupt saved data with a default, debounce autosave for the editor, clear nothing on refresh, and test it by adding, editing and deleting notes, refreshing the page, opening a second tab, and checking that both tabs stay in sync without loops.

Chapter questions

  1. Why should display be derived from state, rather than state being read from the display?
  2. Explain the difference between localStorage and sessionStorage, including when you would use each one.
  3. Write code that saves an array of todo objects to localStorage and loads it back safely, handling a missing key and corrupt JSON.
  4. A visitor deletes a task in one tab, but it reappears in another tab. What has gone wrong, and how do you fix it?
  5. Why should passwords and other secrets never be stored in localStorage, and where should they be kept instead?