Skip to the content

Module 1: State, and why parts of a page share it

Module 2: Build a tiny store by hand

Module 3: Install it and make your first store

Module 4: Atoms in depth

Module 5: Maps: objects changed key by key

Module 6: Computed stores: values worked out from others

Module 7: Actions: the logic lives beside the store

Module 8: Stores in React components

Module 9: Islands and other frameworks

Module 10: Lazy stores and their lifecycle

Module 11: Remembering state between visits

Module 12: Async data: loading, errors and races

Module 13: Testing stores

Module 14: Performance, batching and memory

Module 15: Stores and the server

Module 16: Designing a store module’s API

Module 17: Mastery challenge

Answer key

  1. 1. State, and why parts of a page share it · When state changes, the screen must follow · QuizWhich of these are state on a shop page? Pick all that apply.A. The number of items in the cart; C. Whether the visitor chose dark or light mode; D. The text typed into the search box so farState is what changes while the page is used. The cart count, the theme and the search text all change as the visitor acts. The logo is the same for everyone, always: it is part of the page, not something the page has to remember.
  2. 1. State, and why parts of a page share it · A global variable is shared, but nobody hears it change · QuizWith the code above, a visitor clicks “Add to cart” three times. What does the badge show?B. 0The variable does reach 3, but the badge copied the value once, at the start, and nobody asked it to look again. That is the real problem with globals: anyone can change them, and nobody is told. You could make addToCart update the badge too, but then it must know every widget that shows the cart, and the next widget someone adds will be forgotten.
  3. 1. State, and why parts of a page share it · Check: how a shared store flows · Put in orderPut the life of one cart change in order.1. The badge and the checkout panel subscribe to the cart store 2. The visitor clicks “Add to cart” in the product list 3. The product list asks the store to change the cart 4. The store notifies every subscriber 5. The badge and the checkout panel redraw with the new cartSubscribing comes first, usually when a widget appears. After that, any change, from anywhere, flows the same way: change the store, the store tells everyone, everyone redraws. The product list never talks to the badge directly.
  4. 1. State, and why parts of a page share it · Not all state belongs in a shared store · QuizWhich of these is the best fit for a shared store?B. The items in the cart, shown by the badge and the checkout panelThe cart is read and changed by several widgets: that is exactly a shared store’s job. One card’s expanded section matters to that card alone, so it can stay inside it. A results page belongs in the URL, so a reload or a shared link keeps it. A value that never changes isn’t state at all.
  5. 2. Build a tiny store by hand · An object groups values and functions under names · QuizGiven the mug object above, what is mug.price / 100?B. 12mug.price reads the price property, 1200, and dividing by 100 gives the number 12. It isn’t the text "12.00": turning a number into nicely formatted text is a separate step.
  6. 2. Build a tiny store by hand · Using it: the badge finally follows the cart · Fill the blanksFinish set so every listener hears about the new value.1: listeners, 2: listener(value)Notifying is just calling every saved callback with the new value. listener(value) and listener(newValue) are the same here, since value was just set to newValue.
  7. 2. Build a tiny store by hand · Subscribing should hand back a way to stop · QuizWhat does this log?A. light, darksubscribe calls the listener once immediately (light), then on the first set (dark). After stop(), the listener is out of the list, so the last set reaches nobody. console.log prints its input in the console.
  8. 2. Build a tiny store by hand · Setting the same value again shouldn’t notify · QuizWhich of these is true?B. Object.is("dark", "dark")Two pieces of text with the same letters are the same value. Each […] or {…} written out creates a new object, so two of them are never the same, whatever they contain. This one rule explains much of how stores behave, and you will meet it again in module 4.
  9. 2. Build a tiny store by hand · Try it: build the store yourself · Code challengeFinish createStore: subscribe calls the listener at once and returns a function that removes it, and set notifies every listener, but only when the value really changed (Object.is).One solution:
    Solution
    function createStore(initial) {  let value = initial  const listeners = []  return {    get() {      return value    },    set(newValue) {      if (Object.is(value, newValue)) return      value = newValue      for (const listener of listeners) listener(value)    },    subscribe(listener) {      listeners.push(listener)      listener(value)      return () => {        const index = listeners.indexOf(listener)        if (index !== -1) listeners.splice(index, 1)      }    }  }}
    Three small promises, each one line or so: call the listener straight away, skip set when Object.is says nothing changed, and hand back a function that takes the listener out of the list. The equality check must come before the value is replaced, or it would always compare the new value with itself. The real atom keeps exactly these promises.
  10. 3. Install it and make your first store · A module is a file that shares what it exports · QuizThe badge and the checkout panel both import $cart from stores/cart.js. How many carts are there?B. One, shared by every importerThe file runs the first time something imports it; every later import receives what that first run made. So both widgets hold the same store object, and a change made through one is seen by the other.
  11. 3. Install it and make your first store · Store names start with $ · Fill the blanksMake a store for whether the menu is open, starting closed, and open it.1: atom, 2: $menuOpen, 3: false, 4: setAn atom can hold any value; here a yes-or-no (a boolean: true or false). The $ name marks it as a store. set(true) opens it and notifies whoever subscribed.
  12. 3. Install it and make your first store · Reading a type error · Find the bugThis store should hold the signed-in user’s name, or null when nobody is signed in. Which line makes set("Ana") a type error?Line 3: export const $userName = atom<string | null>(null)TypeScript guesses a store’s type from its first value. From null alone it guesses “always null”, so setting a name is refused. Give the type when the starting value is narrower than what the store will hold: atom<string | null>(null).
  13. 4. Atoms in depth · set() notifies only when the value really changed · QuizWhat does this log?B. now 1, now 2The second set(1) gives the value the store already has, so nobody is notified. Nothing is logged for 0 either: listen, unlike subscribe, doesn’t call you with the current value when you start (next slide). Each real change notifies at once.
  14. 4. Atoms in depth · subscribe() calls you now; listen() waits for a change · QuizWhat does this log, in order?A. sub light, sub dark, listen darksubscribe calls its listener straight away with the current value (sub light). listen waits. On set, listeners run in the order they were added: the subscriber first, then the listener.
  15. 4. Atoms in depth · Both return a function that stops listening · Fill the blanksLog theme changes until the settings panel closes.1: listen, 2: set, 3: stop()listen gives the new and the old value, and doesn’t run at once, so the first log comes from the set. Calling the returned function removes the listener. The backticks make a template string: text where ${…} is replaced by a value.
  16. 4. Atoms in depth · Find the change in place · Find the bugClicking “Sort by name” leaves the checkout panel unchanged. Which line is wrong?Line 9: $cart.set($cart.get().toSorted(byName))sort reorders the array in place and returns that same array. set sees the same object, decides nothing changed, and notifies nobody, while the stored array has been quietly reordered under every reader. toSorted returns a sorted copy. addToCart is fine: it builds a new array.
  17. 4. Atoms in depth · Try it: add and remove without changing in place · Code challengeFix addItem and removeItem so they set a new array: [...cart, item] to add, filter to remove the item with that id.One solution:
    Solution
    import { atom } from 'nanostores'
    type Item = { id: string; name: string }
    export const $cart = atom<Item[]>([])
    export function addItem(item: Item) {  $cart.set([...$cart.get(), item])}
    export function removeItem(id: string) {  $cart.set($cart.get().filter((item) => item.id !== id))}
    push and splice change the array the store already holds, so set is never called and Object.is would see the same array anyway: no listener runs, and the badge stays stale. Building a new array with spread or filter gives the store a different object, so it notifies, and a listener’s oldValue really is the old cart.
  18. 4. Atoms in depth · Store types: writable, readable, and the value inside · QuizStores also have a .value property. Why use get()?B. get() always gives the current value, preparing the store if needed; .value is a low-level field that can be empty while nobody listensSome stores, like the derived stores of module 6, only work out their value while someone listens. get() makes sure the value is ready; .value reads the field as it is, possibly undefined. Neither subscribes you: to follow changes, listen or subscribe.
  19. 5. Maps: objects changed key by key · Equal values don’t notify; undefined removes a key · QuizAfter $prefs.setKey("withTax", undefined), what is "withTax" in $prefs.get()?B. false: the key is goneA map treats undefined as “remove this key”: the new object simply has no withTax. That is why optional keys (withTax?:) are the ones you can clear. "key" in object checks whether a key exists.
  20. 5. Maps: objects changed key by key · A cart keyed by product id · Fill the blanksWrite removeOne: take one away, and drop the product when it reaches zero.1: undefined, 2: setKey, 3: now - 1Setting a key to undefined removes it, so a product with zero left disappears from the cart rather than lingering as 0. Otherwise one less is stored under the same key, in a new object.
  21. 5. Maps: objects changed key by key · Try it: a cart keyed by id · Code challengeWrite addOne(id), which adds 1 to that product (from 0 if missing), and removeOne(id), which takes 1 away and removes the key when it would reach 0. Use setKey.One solution:
    Solution
    import { map } from 'nanostores'
    export const $quantities = map<Record<string, number>>({})
    export function addOne(id: string) {  const now = $quantities.get()[id] ?? 0  $quantities.setKey(id, now + 1)}
    export function removeOne(id: string) {  const now = $quantities.get()[id] ?? 0  $quantities.setKey(id, now > 1 ? now - 1 : undefined)}
    get()[id] ?? 0 treats a missing product as 0. setKey(id, undefined) deletes the key, so the cart never fills with zeros that every reader would have to skip. setKey also makes a new object and tells listeners which key changed, so a listenKeys on one product hears only about that product.
  22. 5. Maps: objects changed key by key · Listen only to the keys you care about · QuizWhat does this log?B. theme darkA change to name isn’t one this listener asked for. The first theme change notifies. The second sets the value the key already has, so the map notifies nobody. And listenKeys, like listen, doesn’t call you at the start: that is subscribeKeys.
  23. 5. Maps: objects changed key by key · One map, or several atoms? · QuizA sign-up form has email, password and busy (sending or not). Two widgets show only busy. Which shape fits best?A. A map of the form, with the widgets listening to the busy keyThe fields belong together (they are sent together), so a map fits. Readers that care about one field listen to that key and ignore the others. A single atom works too, but then every keystroke notifies every reader, including the two that only show busy.
  24. 6. Computed stores: values worked out from others · Derive from several stores at once · Fill the blanksMake $freeShipping: true when the cart’s total is at least 50.00.1: computed, 2: $total, 3: >=The source is $total, already in cents, so 50.00 is 5000. The result is a boolean. Because it is derived from $total, which is derived from $cart, adding an item updates all three in a chain.
  25. 6. Computed stores: values worked out from others · Try it: a total with a discount · Code challengeMake $total a computed store from $cart and $discount: the sum of price * quantity, minus $discount percent, rounded to whole cents with Math.round.One solution:
    Solution
    import { atom, computed } from 'nanostores'
    type Line = { price: number; quantity: number }
    export const $cart = atom<Line[]>([])export const $discount = atom(0) // percent
    export const $total = computed(  [$cart, $discount],  (cart, discount) => {    const sum = cart.reduce((s, l) => s + l.price * l.quantity, 0)    return Math.round(sum * (1 - discount / 100))  })
    With an array of stores, the function gets their values in the same order, and runs again when either changes. Leaving $discount out of the list is the usual mistake: the total would read a stale discount, or none. 999 * 0.9 is 899.1, so Math.round keeps the result in whole cents.
  26. 6. Computed stores: values worked out from others · A computed store only works while someone watches · QuizHow many times is compute logged?C. 3Creating $sum computes nothing: it is lazy. The first listener wakes it, and it computes once (1). Each set on a source then recomputes it at once (2, 3). In between, a listener of $sum sees 12: the new $a with the old $b. Module 14 shows how batch avoids that halfway value.
  27. 6. Computed stores: values worked out from others · An unchanged result notifies nobody · QuizThe cart total goes 6000 → 7000 → 4000 cents. How many times are $freeShipping’s listeners called?C. 1$freeShipping is true at 6000 and still true at 7000: same result, no notification. At 4000 it becomes false: one notification. A computed store that returns a new object every time, though, notifies on every source change, since a new object is never Object.is the old one.
  28. 6. Computed stores: values worked out from others · The function must be pure · Find the bugAnalytics receives cart totals at odd moments, sometimes twice, sometimes never. Which line is the mistake?Line 6: // send from an action, or from $total.listen(...)Sending data is a side effect: something done to the outside world. The function runs when Nano Stores needs a value, not once per real change: maybe on get(), maybe not at all while nobody listens. Keep computed pure, and send from the action that changed the cart, or from a listener.
  29. 6. Computed stores: values worked out from others · Derive, don’t duplicate · Find the bugAfter the visitor switches currency, the total is right. After they add an item, it is the old total. Which line is the design error?Line 3: export const $shown = computed([$total, $prefs], (t, p) => format(t, p.currency))$shown is a hand-kept copy that only follows $prefs, so a change to the cart never reaches it. Adding a second listener would patch this case and miss the next. Make it a computed of both sources, and delete the listener: it updates whenever either changes.
  30. 7. Actions: the logic lives beside the store · Why one place for the rules pays off · Fill the blanksAdd the rule: never more than 10 of one product.1: min, 2: map, 3: cappedMath.min picks the smaller number, so 15 becomes 10. map builds a new array with the one line changed. Every widget that changes quantities goes through here, so none can break the rule.
  31. 7. Actions: the logic lives beside the store · Actions read the current value with get() · QuizA widget saved const cart = $cart.get() when it first drew, and later calls $cart.set([...cart, item]). What goes wrong?B. Items added by other widgets since then are lostcart is the array as it was back then. Building on it silently drops whatever changed since. Read the store at the moment of the change, which actions do naturally: $cart.set([...$cart.get(), item]).
  32. 7. Actions: the logic lives beside the store · Try it: a to-do list with rules · Code challengeFinish addTodo(text): trim the text, ignore it if it's empty, and add { id, text, done: false } with id from nextId(). Then write toggleTodo(id), which flips done on that task only.One solution:
    Solution
    import { atom } from 'nanostores'
    type Todo = { id: number; text: string; done: boolean }
    export const $todos = atom<Todo[]>([])let lastId = 0const nextId = () => ++lastId
    export function addTodo(text: string) {  const trimmed = text.trim()  if (trimmed === '') return  const todo = { id: nextId(), text: trimmed, done: false }  $todos.set([...$todos.get(), todo])}
    export function toggleTodo(id: number) {  const todos = $todos.get().map((todo) =>    todo.id === id ? { ...todo, done: !todo.done } : todo  )  $todos.set(todos)}
    map builds a new array; the toggled task is a new object ({ ...todo, done: !todo.done }) and every other task is kept as the very same object. That's why the last test checks toBe(dog): a widget drawing the dog's row can tell nothing changed. Trimming and refusing empty text in the action means no button or keyboard shortcut can skip the rule.
  33. 7. Actions: the logic lives beside the store · Export a read-only store, so only actions write · QuizA plain JavaScript file (no type checking) calls $cart.set([]) on the exported store. What happens?B. It works: readonlyType only changes the type, not the objectreadonlyType is the identity function with a narrower type. Type checking is what forbids the call. That is enough when the whole project is checked, and a reason to keep it so.
  34. 7. Actions: the logic lives beside the store · Name actions for what happens, not how · Put in orderPut the steps of a good action in order.1. Read the current values with get() 2. Check the rules and stop early if the change isn’t allowed 3. Build the new value without changing the old one 4. Call set (or setKey) on the storeReading at the start gives the latest state. Checking rules before building anything keeps invalid changes out. Building a new value respects the “new object means changed” rule. Setting last notifies everyone once the value is ready.
  35. 8. Stores in React components · Events call actions · QuizThe visitor clicks “Add”. Which components re-render?B. CartBadge (it reads $itemCount), and not ProductListOnly components subscribed with useStore to a store that changed re-render. addToCart changes $cart, which changes $itemCount, which the badge reads. The list only called a function; nothing it reads changed.
  36. 8. Stores in React components · A component can read several stores · Fill the blanksMake a theme toggle that reads the preference and switches it through an action.1: useStore, 2: $prefs, 3: setTheme(next)useStore($prefs) gives the map’s object; { theme } picks one property out of it. The click calls the action rather than $prefs.setKey directly, so the rules about themes stay in prefs.ts.
  37. 8. Stores in React components · Re-render only for the keys you read · QuizThe visitor types 12 letters of their email, then submits (busy becomes true). How many times does SubmitButton re-render after its first render?C. 1Each letter changes email, which isn’t in keys, so the button isn’t told. Only the change to busy re-renders it. Without keys, it would have been 13.
  38. 8. Stores in React components · Nano Store, React state, or React context? · QuizA tooltip opens when its own icon is hovered. Nothing else cares. Where should “open” live?B. React useState in the tooltipState used by one component belongs in that component. A store would make it visible, and changeable, from everywhere for no reason. Move state into a store when a second, separate reader appears.
  39. 9. Islands and other frameworks · Islands have no common parent, but they share modules · QuizWhy can’t the badge and the list share the cart through React context?B. Each island is its own React root, so no provider sits above bothContext passes values down one tree, from a provider to the components inside it. Separate islands are separate trees with nothing above them. A store module sits outside every tree, so all of them can reach it.
  40. 9. Islands and other frameworks · Plain scripts can use stores too · QuizA plain script and a React island both import $prefs. The island calls setTheme("dark"). Does the script’s listener run?A. Yes: both use the one store the module madeThere is only one kind of store: plain JavaScript. @nanostores/react is a thin layer that subscribes components to it. Any code on the page that imports the module gets the same store.
  41. 9. Islands and other frameworks · Hydrate with the starting value, then catch up · Quiz$itemCount is a computed store. What does useStore($itemCount, { ssr: "initial" }) give during hydration?B. undefined: a computed store has no starting value of its own"initial" reads the starting value a store was created with. An atom has one; a computed store was created without one, so it is undefined. For a computed store, pass a function: ssr: () => 0, or base the island on an atom.
  42. 10. Lazy stores and their lifecycle · Try it: open a feed only while someone listens · Code challengeIn createStock(feed), use onMount on $stock so it calls feed.open() when it mounts, and returns a cleanup that calls feed.close().One solution:
    Solution
    import { atom, onMount } from 'nanostores'
    export type Feed = { open(): void; close(): void }
    export function createStock(feed: Feed) {  const $stock = atom(0)  onMount($stock, () => {    feed.open()    return () => feed.close()  })  return $stock}
    onMount runs when the store gets its first listener, not when it's created, and not again for the second. The function it returns runs when the store unmounts, about a second after the last listener leaves (STORE_UNMOUNT_DELAY), which is why the last test waits. Passing feed in, instead of importing a real connection, is what lets the tests hand in a fake that records its calls.
  43. 10. Lazy stores and their lifecycle · Cleanup waits one second · QuizThe only listener of $online unsubscribes. 300 ms later, another component subscribes. How many times has onMount’s function run in total?A. OnceThe cleanup was scheduled for 1000 ms after the last listener left. A listener came back after 300 ms, so the store is still mounted when the timer fires, and nothing is cleaned up. The second subscriber joins a store that is already running: onMount doesn’t run again.
  44. 10. Lazy stores and their lifecycle · get() on a sleeping store wakes it, briefly · QuizWhat does this log?B. start, then stop about a second laterThe first get() mounts the store (start) and leaves, scheduling cleanup in a second. The second get(), a moment later, finds the store still active, so onMount doesn’t run again. A second later, nobody listens, and stop runs once.
  45. 10. Lazy stores and their lifecycle · Undo everything onMount starts · Find the bugAfter the dashboard has been opened and closed a few times, the clock ticks several times a second. Which line is the cause?Line 5: const id = setInterval(tick, 1000); return () => clearInterval(id)setInterval repeats a function every 1000 ms until clearInterval stops it. Nothing stops it here, so each time the store mounts again, one more timer joins the others. Keep the id that setInterval returns and return a cleanup that clears it.
  46. 10. Lazy stores and their lifecycle · onSet can check or refuse a change · Fill the blanksRefuse any theme that isn’t light or dark.1: onSet, 2: newValue, 3: abort()onSet sees the value before it is stored. Calling abort() keeps the old value and notifies nobody. Each of these functions returns its own “stop” function too, to remove the hook.
  47. 10. Lazy stores and their lifecycle · effect() runs code now and whenever stores change · QuizWith the effect above, the visitor goes offline, then switches currency. How many times has the function run, counting the first?C. 3Once immediately, then once per change to either store: offline (2), currency (3). Like subscribe, it starts with the current values. Unlike computed, it is for doing things (side effects), and returns no store.
  48. 11. Remembering state between visits · Anything but text needs encoding · QuizWhy is persistentJSON safer than persistentAtom with { encode: JSON.stringify, decode: JSON.parse }?B. If the saved text is broken, JSON.parse throws while creating the store; persistentJSON falls back to the initial valueSaved text can be damaged: edited by hand, cut off, written by an older version. JSON.parse on bad text throws an error, and here it would throw when the module loads, breaking every widget that imports it. persistentJSON catches that and starts from the initial value.
  49. 11. Remembering state between visits · A cart that survives a reload · Fill the blanksSave the currency, a word, under the key currency, defaulting to usd.1: persistentAtom, 2: 'currency', 3: 'usd'Text needs no encode or decode. The first argument is the key in localStorage, the second the value used when nothing is saved yet. Quotes included: both are text.
  50. 11. Remembering state between visits · persistentMap saves one key per field · QuizAfter $settings.setKey("currency", undefined), what is in localStorage for the currency?B. Nothing: the settings:currency key is removedAs with any map, undefined deletes the field, and the persistent map deletes its saved key to match. On the next visit the field is missing from storage, so the initial value fills it in.
  51. 11. Remembering state between visits · Tabs stay in sync, while the store is mounted · QuizIn tab B nothing listens to $theme. Tab A switches to dark. Then a widget in tab B subscribes. What does it see?B. dark: on mounting, the store re-reads storageThe tab listeners are added in the store’s onMount, so a sleeping store doesn’t hear other tabs. But mounting also re-reads storage, so it catches up the moment something listens again.
  52. 11. Remembering state between visits · Saved values are user input: check them · Find the bugAn older version of the site saved themes like "sepia". Returning visitors now get an unstyled page. Which line lets the old value in?Line 8: decode: (text) => (isTheme(text) ? text : 'light')text as Theme tells TypeScript to trust the text, without checking anything. Any saved word gets through. Decode with the guard: a known theme passes, anything else becomes the default. isTheme was written for exactly this and never used.
  53. 11. Remembering state between visits · Saved values and server-drawn HTML · QuizA returning visitor has 2 items saved. How should CartBadge read $cart to avoid a mismatch?B. useStore($cart, { ssr: () => [] }): hydrate with the empty cart the server used, then show 2The server’s HTML was drawn with an empty cart. Hydrating with that same value matches it; one render later the badge shows 2. "initial" doesn’t help here: a persistent store is created from the saved value, so in the browser the value it was created with is already the 2 saved items. Reading storage in the component causes the very mismatch you want to avoid, and breaks on the server.
  54. 12. Async data: loading, errors and races · Model loading, ready and failed explicitly · QuizWhy not { items: Product[]; loading: boolean; error: string | null }?B. It allows impossible combinations, like loading with an error and old items, that every reader must guess aboutThree independent fields give eight combinations, most of them meaningless. Readers end up with tangled ifs, and different widgets disagree on what “loading with an error” means. A union lists exactly the states that exist.
  55. 12. Async data: loading, errors and races · An old answer can arrive after a new one · Find the bugResults sometimes don’t match the search box. Which line needs a check before it?Line 6: if (query === $query.get()) $results.set(items)By the time an answer arrives, the visitor may have typed more. Before storing it, check that it still answers the current query; if not, drop it. Cancelling the old request (next slide) also saves the network the work.
  56. 12. Async data: loading, errors and races · Cancel what nobody wants any more · Fill the blanksCancel the request when the store unmounts.1: AbortController, 2: signal, 3: abort()The controller’s signal goes with the request; abort() cancels it. Returning that from onMount ties the request’s life to the store’s: no listeners, no request.
  57. 12. Async data: loading, errors and races · Try it: load a list, or say why not · Code challengeFinish loadBooks(fetchBooks): set { state: 'loading' }, await fetchBooks(), then set { state: 'ready', items }, or { state: 'failed', error: 'Could not load books' } if it throws.One solution:
    Solution
    import { atom } from 'nanostores'
    type Books =  | { state: 'idle' | 'loading' }  | { state: 'ready'; items: string[] }  | { state: 'failed'; error: string }
    export const $books = atom<Books>({ state: 'idle' })
    export async function loadBooks(  fetchBooks: () => Promise<string[]>) {  $books.set({ state: 'loading' })  try {    const items = await fetchBooks()    $books.set({ state: 'ready', items })  } catch {    $books.set({ state: 'failed', error: 'Could not load books' })  }}
    loading must be set before the await: the test reads the store right after calling loadBooks, before the promise settles, which is what a spinner sees. The try/catch turns a failed request into a state the page can show; without it the error escapes to whoever called the action, usually a click handler that ignores it, and the spinner never stops.
  58. 12. Async data: loading, errors and races · task() lets others wait for a store’s work · QuizWhy await allTasks() rather than waiting 100 ms with a timer?B. It waits exactly as long as the work takes: never too short (flaky) or too long (slow)A guessed delay is either too short on a slow machine, so the test fails at random, or longer than needed, so every run is slow. allTasks resolves the moment the counted work is done.
  59. 13. Testing stores · Stores outlive each test: reset them · Find the bugEach test passes alone, but run together the second fails. Which line should run before each test instead?Line 4: beforeEach(() => clearCart())beforeAll runs once before all the tests, so the second test starts with the mug still in the cart and finds two items. beforeEach runs before every test, so each starts from an empty cart, whatever order they run in.
  60. 13. Testing stores · Try it: reset before each test · Code challengeAdd a beforeEach under the store that calls clearCart(), so every test starts with an empty cart.One solution:
    Solution
    import { atom } from 'nanostores'
    export const $cart = atom<string[]>([])export const addToCart = (id: string) =>  $cart.set([...$cart.get(), id])export const clearCart = () => $cart.set([])
    beforeEach(() => clearCart())
    A module is loaded once, so $cart lives as long as the whole test file. beforeEach runs before every test, so each one starts from the same empty cart whatever ran before it, or whatever order the tests run in. beforeAll would clear it only once, at the start, and the second test would still find the mug.
  61. 13. Testing stores · cleanStores removes listeners and runs cleanups · QuizA test sets $theme to "dark". The next test only calls cleanStores($theme) first. What is $theme.get() there?B. "dark"cleanStores cleans an atom’s listeners and lifecycle, not its value. For computed stores it does forget the remembered result, which is recomputed from the sources. Reset atoms yourself, ideally with an exported starting value or action.
  62. 13. Testing stores · Test cleanup under fake timers · Fill the blanksCheck that $online stops watching the window a second after its last listener leaves.1: useFakeTimers, 2: STORE_UNMOUNT_DELAY, 3: toHaveBeenCalledWithvi.useFakeTimers() takes over setTimeout; advanceTimersByTime moves the fake clock forward, running the scheduled cleanup. vi.spyOn watches calls to a function, so the test can assert the listener was removed. Tests touching window need a browser-like environment, such as Vitest’s jsdom or happy-dom.
  63. 13. Testing stores · Wait for async store work with allTasks() · Put in orderPut the steps of testing a store that loads on mount in order.1. Replace the network with a fake answer 2. Add a listener, so the store mounts and starts loading 3. Await allTasks() 4. Assert on the store’s value 5. Remove the listener and clean the storeThe fake must be in place before the store mounts, since mounting starts the request. allTasks only knows about work wrapped in task(), as in module 12. Cleaning up last keeps one test’s mounted store from affecting the next.
  64. 13. Testing stores · Persistent stores need a test storage engine · QuizWhy call useTestStorageEngine() in a setup file, rather than inside a test?B. Persistent stores read storage when they are created, which happens when the test file’s imports run, before any testImports run first, and persistentAtom reads its saved value at creation. A setup file runs before the test file’s imports, so stores are created against the test engine from the start.
  65. 14. Performance, batching and memory · batch() delivers several writes as one change · QuizHow many times is compute logged after the first listener is added?A. Once moreWithout batch, each source change recomputes $sum (module 6). Inside it, $sum’s listener on its sources waits, and runs once when the batch ends, seeing both new values: one computation, and no halfway 12.
  66. 14. Performance, batching and memory · Try it: apply a coupon in one change · Code challengeWrap the two set calls in applyCoupon in batch(() => { … }), so $summary tells its listeners once, with both new values.One solution:
    Solution
    import { atom, batch, computed } from 'nanostores'
    export const $discount = atom(0)export const $code = atom('')export const $summary = computed(  [$discount, $code],  (discount, code) => `${discount}% off (${code || 'no code'})`)
    export function applyCoupon(code: string, percent: number) {  batch(() => {    $discount.set(percent)    $code.set(code)  })}
    Without batch, the first set updates $summary straight away, and its listener sees a halfway state, 10% off (no code), before a second call brings the code: two calls, one of them wrong. Inside batch, listeners wait until the function returns, then each runs at most once with the final values. The batch must stay synchronous: an await inside would end it early.
  67. 14. Performance, batching and memory · batch() is synchronous · Find the bugListeners still see the products arrive without the matching total. Which line breaks the batch?Line 2: loadCart().then((fresh) => batch(() => { /* both sets */ }))The batch closes when its function returns, and an async function returns at its first await. Everything after runs later, outside the batch. Wait first, then batch the writes in one synchronous function.
  68. 14. Performance, batching and memory · Computed stores that return new objects always notify · QuizA line’s quantity goes from 2 to 3. Which readers are notified: of $summary (left), $count, $empty?B. $summary and $count; not $emptyThe count changed, so $count notifies, and $summary’s new object does too. $empty was false and is still false: same value, no notification, so a “Your cart is empty” component stays still.
  69. 14. Performance, batching and memory · Listeners run in a queue, in order · QuizWhat does this log?B. a1 1, a2 1, b 1Setting $a queues a1 and a2. While a1 runs, $b.set(1) stores the new value at once but queues b behind a2. So every listener of $a hears its change before any listener of $b runs (though a2 would already read 1 from $b.get()). This keeps the order predictable, but chains of listeners that set stores are hard to follow: prefer computed stores and actions.
  70. 14. Performance, batching and memory · Memory leaks: listeners that outlive their widget · Find the bugEach time the mini-cart popup opens and closes, memory grows and old popups’ code still runs. Which line leaks?Line 3: const stop = $cart.subscribe((cart) => popup.update(cart)) // call stop() in close()The listener holds popup, so the store holds the popup forever, even after it leaves the page, and keeps updating it. Keep the function subscribe returns and call it in close(). Framework bindings do this for you; hand-written subscriptions are where leaks hide.
  71. 15. Stores and the server · Never put one visitor’s data in a module-level store on the server · Find the bugVisitors sometimes see someone else’s name in the header. Which line causes it?Line 6: // delete it: pass the user to the island as a propThe page code runs on the server, once per request. Setting a module-level store there writes to the one $user every request shares: another request can draw with it, or read it after an await. Pass per-request data to islands as props (the next slide).
  72. 15. Stores and the server · Hand per-request data to islands as props · QuizWhich of these can an Astro page pass to an island as a prop?A. The user’s name and a list of their ordersProps cross from the server to the browser written into the page, so they must be plain data: text, numbers, lists, plain objects. A store or a function can’t be written down and rebuilt in the browser. Islands import stores themselves, and import the actions they call.
  73. 15. Stores and the server · Store side effects can run during server rendering · QuizWithout the guard, what happens when the page with the online badge is built on a server?B. ReferenceError: navigator is not defined (or window), failing the build or the requestuseStore reads the store while drawing, get() mounts it, and onMount runs where navigator and window are missing (newer Node versions do have a navigator, but no window events). Guard browser-only work, so the server draws the starting value and the browser does the rest.
  74. 15. Stores and the server · Seeding a store from server data, in the browser · Put in orderPut the life of a server-drawn page with a store in order.1. The server draws the island’s HTML from its props 2. The browser receives the HTML and shows it 3. The island hydrates, matching the server’s HTML 4. Browser-only code runs: effects, onMount work, saved values 5. The island re-renders with the live store valuesEverything that differs between server and browser (saved values, the URL, the network) belongs after hydration. Getting this order right is what keeps the first frame correct and hydration free of mismatches.
  75. 16. Designing a store module’s API · Keep the writable store and helpers private · QuizWhich of these should stores/cart.ts export? Pick all that apply.A. $cart, typed read-only; C. addToCart and clearCart; E. The CartLine typeReaders need the read-only store, the actions and the types to use them. Exporting the writable atom invites writes that skip the rules. A helper like countItems is an internal detail: export it only if other code truly needs it, and test it through $itemCount otherwise.
  76. 16. Designing a store module’s API · Try it: one store per product, shared · Code challengeMake stockOf(id) cache its stores in cache: return the one already made for that id, or make an atom(0), store it, and return it.One solution:
    Solution
    import { atom, type WritableAtom } from 'nanostores'
    const cache = new Map<string, WritableAtom<number>>()
    export function stockOf(id: string) {  let $stock = cache.get(id)  if (!$stock) {    $stock = atom(0)    cache.set(id, $stock)  }  return $stock}
    Without the cache, every call makes a fresh store: the header's badge and the product page would each watch their own copy, and a set on one would never reach the other. toBe checks it's the same object, which is exactly the promise a cache makes. In a real app, the store's onMount cleanup would also remove it from the cache, as on the previous slide.
  77. 16. Designing a store module’s API · mapCreator does this for maps · Fill the blanksRead one product’s stock in a component.1: useStore, 2: Product(id)Product(id) gives the same store for the same id, so calling it on every render is fine. While the stock hasn’t arrived, the key is missing, and ?? shows a placeholder.
  78. 16. Designing a store module’s API · Layer modules so dependencies point one way · QuizclearCart must also reset the checkout’s error message, which lives in checkout.ts, which imports the cart. Where should that logic go?B. In checkout.ts: an action there, or $cart.listen there, resets the error when the cart emptiesThe checkout depends on the cart, not the other way round. Let checkout.ts react to the cart: the cart stays unaware of checkout, and there is no cycle. Copying the reset into widgets brings back the scattered rules actions were meant to remove.
  79. 16. Designing a store module’s API · Reviewing a store design · Find the bugReview this module. Which line breaks its design the most?Line 4: // delete it: writes go through the actionsExporting the writable atom under another name undoes readonlyType: any widget can now set the cart and skip the merge and cap rules. The other lines are the design working as intended.
  80. 17. Mastery challenge · Predict the notifications: maps and computed · QuizWhat does this log?A. dark false, dark truesubscribe wakes $dark, which computes false and logs it. The currency change recomputes false again: same value, silence. The theme change gives true: logged. The whole-object set is a new object, so $dark recomputes, but still true: silence. Small computed results filter out everything that doesn’t change them.
  81. 17. Mastery challenge · Predict the notifications: batch and old values · QuizWhat does this log?B. 1 → 3Inside the batch, the first set queues the listener with the old value 1; the second finds it already queued and adds nothing. When the batch ends, the listener runs once with the store’s final value, 3, and the old value from before the batch, 1. Listeners see where things started and where they ended, never the steps in between.
  82. 17. Mastery challenge · Predict the notifications: batch and old values · Quiz$online has an onMount that logs start and a cleanup that logs stop. What is logged?B. start, stop at 1 s, start at 1.5 s, stop at 2.5 sThe first listener mounts the store; leaving schedules cleanup for 1 s later. Nobody returns within that second, so stop runs at 1 s. At 1.5 s a new listener mounts it again (start), and its leaving gives stop a second later, at 2.5 s. Had the second listener come at 0.5 s, there would have been only one start.
  83. 17. Mastery challenge · Debug: a row that doesn’t update · Find the bugWhich line is the root cause?Line 6: ? { ...item, quantity: item.quantity + 1 }Object.assign(target, changes) copies the changes into target: it changes the existing line in place. The array is new, so $lines notifies and the badge’s computed count is right, but the memo row receives the very same line object as its prop, decides nothing changed, and skips re-rendering. Spread into a new object instead, as in module 4. The else branch is right.
  84. 17. Mastery challenge · Debug: a flash and a hydration error · QuizWhat is the right fix?B. Use useStore($cart, { ssr: () => [] }), so hydration renders the empty cart the server drew, then the saved oneThe server drew the badge with the initial empty cart; the browser’s store already holds the saved one. Hydrating with the server’s value matches the HTML, and the badge updates one render later. ssr: "initial" would not: a persistent store is created from the saved value, so in the browser that is the saved cart too. Reading storage in render causes the same mismatch, and there is no visitor storage on a server to set from.
  85. 17. Mastery challenge · Design: a remembered, validated setting · Fill the blanksRemember the currency across visits and tabs, accepting only known values.1: persistentAtom, 2: isCurrency(text), 3: readonlyType, 4: setThe decoder lets only known currencies through, so stale or edited values fall back to the default. The writable store stays private; others read $currency and change it through setCurrency. Tab syncing comes for free while the store is mounted.
  86. 17. Mastery challenge · Trace a lazy store’s life on a page · Put in orderThe product list loads on first use and is shown by two islands. Put its life in order.1. The page loads; the store module runs and creates $products in its loading state 2. The first island subscribes: onMount starts the request 3. The second island subscribes and joins the request already running 4. The answer arrives: one set, both islands re-render 5. Both islands are removed; a second later, the cleanup runsModule code runs on import, but lifecycle work waits for a listener. One mount serves every listener; one set reaches all of them; cleanup waits STORE_UNMOUNT_DELAY after the last leaves.
  87. 17. Mastery challenge · Trace a lazy store’s life on a page · QuizWhich of these are safe in an Astro page’s server code? Pick all that apply.A. Passing the visitor’s name to an island as a prop; C. Importing a store module whose onMount checks typeof window firstProps are drawn per request, so they are safe. A guarded onMount does nothing on the server. Setting a module-level store shares one visitor’s data with every request. And the server has no localStorage: that read throws, or, if guarded, never sees the visitor’s saved values.
  88. 17. Mastery challenge · Review a teammate’s store module · QuizWhich review comments are right? Pick all that apply.A. $count should be a computed store of $cart, not a copy kept by hand; B. onMount needs a cleanup that removes the listener, and a server guard; C. $cart is exported writable, so widgets can skip addToCartA hand-kept $count drifts the moment another action forgets it. The onMount neither cleans up nor checks for a browser. Exporting the atom writable lets anyone bypass the rules: use readonlyType. The spread builds a new array, which is correct. A persistentJSON cart would also replace the hand-made storage listener.