Tiny stores you can watch: shared state between separate parts of a page.
Nano Stores is a state manager of a few hundred bytes: small stores you can read, change and subscribe to, outside any framework. Starting from what “state” means, the course builds a shopping cart shared by separate widgets, then covers atoms, maps, computed stores, lazy lifecycles, React and other frameworks, islands, @nanostores/persistent, async data, testing, performance and server rendering.
What you’ll be able to do
Explain what shared state is and why a store you can subscribe to beats passing values around or using globals.
Model state as small atoms, maps and computed stores, with the logic in actions beside them.
Predict exactly when listeners, computed stores and lifecycle events run, and why an update did or didn’t notify.
Share state between React components and separately started islands, keeping server-rendered HTML matching.
Persist settings in localStorage safely, load async data without races, and test all of it.
Design a store module’s API and avoid the expert pitfalls: wasted renders, leaks, and state shared across server requests.
Modules
State, and why parts of a page share it· 10 slides
Build a tiny store by hand· 10 slides
Install it and make your first store· 9 slides
Atoms in depth· 9 slides
Maps: objects changed key by key· 9 slides
Computed stores: values worked out from others· 9 slides
Actions: the logic lives beside the store· 8 slides
Stores in React components· 8 slides
Islands and other frameworks· 10 slides
Lazy stores and their lifecycle· 9 slides
Remembering state between visits· 9 slides
Async data: loading, errors and races· 10 slides
Testing stores· 10 slides
Performance, batching and memory· 10 slides
Stores and the server· 7 slides
Designing a store module’s API· 9 slides
Mastery challenge· 9 slides
Interactive parts (exercises, code to run, walkthroughs, flashcards, checklists) are drawn still and marked “Interactive · disabled in the PDF”. Do them in the app; the answers are at the end.
Module 1: State, and why parts of a page share it
Nano Stores1 / 155
Module 1 · State, and why parts of a page share it
State is what a page remembers right now
Open a small online shop. You add a mug to your cart, switch the site to dark mode, open the menu. The page now remembers three things it didn’t a minute ago: what is in the cart, which theme you chose, and that the menu is open. Everything a page remembers while you use it is called its state.
You do this
The page remembers
Kind of value
Add a mug to the cart
The cart’s items
A list
Pick dark mode
The theme
A word: "dark"
Open the menu
Whether the menu is open
Yes or no: true
Type in the search box
What you typed
Text
State
The values a page remembers while it runs, which change as you use it.
Value
One piece of data: a number like 3, text like "dark", true or false, a list, or a group of named values.
Nano Stores2 / 155
Module 1 · State, and why parts of a page share it
When state changes, the screen must follow
State matters because people see it. When the cart goes from 0 items to 1, the little number on the cart icon must change from 0 to 1. If the value changes but the screen doesn’t, the page is lying to the visitor.
The visitor clicks a button.
Code changes a remembered value.
Every part of the page that shows that value draws it again.
The loop every interactive page runs: act, remember, show.
QuizInteractive · disabled in the PDF
Which of these are state on a shop page? Pick all that apply.
Which of these are state on a shop page? Pick all that apply.
Nano Stores3 / 155
Module 1 · State, and why parts of a page share it
Several separate widgets often need the same state
Pages are built from widgets: self-contained pieces, each with its own job. On our shop page, three widgets care about the cart. The header shows a badge with the number of items. The product list has “Add to cart” buttons. The checkout panel lists the items and the total.
Three widgets, one cart. If they disagree, the visitor notices.
Widget
A self-contained piece of a page with one job: a menu, a badge, a form. In code it is often called a component.
Shared state
State that more than one widget reads or changes, like the cart here.
Nano Stores4 / 155
Module 1 · State, and why parts of a page share it
Passing a value from widget to widget gets tangled
One way to share is to keep the cart in a widget high up the page, and hand it down to every widget below that needs it. That works for a small page. On a real one, the cart has to travel through widgets that don’t care about it, just to reach the ones that do.
Pagekeeps the cart
Layoutpasses cart on
Headerpasses cart on
Badgeuses it
Mainpasses cart on
Product listuses it
The cart travels through Layout and Main only to reach the badge and the list.
And sometimes there is no common parent. Some pages are made of widgets that are started separately, each on its own, with nothing above them that they share. Then passing values down is not just tedious: it is impossible. You will meet these “islands” in module 9.
Nano Stores5 / 155
Module 1 · State, and why parts of a page share it
A global variable is shared, but nobody hears it change
Another idea: put the cart in a variable that every widget can reach. A variable is a name that holds a value. A global variable is one the whole page can see. Sharing is solved. Being told about changes is not.
WalkthroughInteractive · disabled in the PDF
JavaScript
1// one variable the whole page can see2let cartCount =034// the header badge draws itself once, at start5badge.textContent = cartCount67// later, the product list adds an item8functionaddToCart(){9 cartCount = cartCount +110}
Lines 1-2:let makes a variable named cartCount holding 0. Lines starting with // are comments: notes for people, ignored by the computer.
Lines 4-5: The badge copies the value onto the screen. badge.textContent is the text inside the badge element.
Lines 7-10: A function is a named list of steps to run later. When “Add to cart” is clicked, it adds one to the count.
Lines 9: The variable is now 1, but nothing tells the badge. It still shows 0.
QuizInteractive · disabled in the PDF
With the code above, a visitor clicks “Add to cart” three times. What does the badge show?
With the code above, a visitor clicks “Add to cart” three times. What does the badge show?
Nano Stores6 / 155
Module 1 · State, and why parts of a page share it
The fix: let widgets subscribe to changes
Think of a newspaper. You don’t walk to the printer every morning to check for news: you subscribe once, and each new edition comes to you. Shared state can work the same way. The value lives in one place, and any widget can subscribe to it. When it changes, every subscriber is told and can redraw itself.
Whoever changes the cart doesn’t need to know who is listening.
Store
A place that holds a value, lets code change it, and tells subscribers when it does.
Subscribe
Ask a store to call you back every time its value changes.
Observer pattern
The general name for this design: one thing holds data, others “observe” it and are notified of changes.
Nano Stores7 / 155
Module 1 · State, and why parts of a page share it
Check: how a shared store flows
Put in orderInteractive · disabled in the PDF
Put the life of one cart change in order.
1.The badge and the checkout panel subscribe to the cart store
2.The product list asks the store to change the cart
3.The badge and the checkout panel redraw with the new cart
4.The store notifies every subscriber
5.The visitor clicks “Add to cart” in the product list
Nano Stores8 / 155
Module 1 · State, and why parts of a page share it
Nano Stores is a tiny library of such stores
Nano Stores is a library: ready-made code you add to your project instead of writing it yourself. It gives you stores you can read, change and subscribe to. Its core is a few hundred bytes, smaller than most images on a page.
Framework-free. A store is plain JavaScript. It works the same in React, Vue, Svelte, a plain script, a test or a server.
Small stores. You make one store per piece of state (the cart, the theme), not one giant object.
Logic lives beside the stores. The code that changes the cart sits next to the cart, not inside each widget.
Only what you use is shipped. Tools that pack your code for the browser leave out the parts you never import.
Library
Code someone else wrote and published, which you add to your project and call.
Framework
A larger library that shapes how you build a whole interface: React, Vue, Svelte are frameworks.
Nano Stores9 / 155
Module 1 · State, and why parts of a page share it
Not all state belongs in a shared store
State
Best home
Example
Used by one widget only
That widget’s own state
Whether one dropdown is open
Shared by several widgets
A Nano Store
The cart, the theme
Kept between visits
A store saved to the browser (module 11)
The chosen theme
Part of the address
The URL
/products?sort=price
QuizInteractive · disabled in the PDF
Which of these is the best fit for a shared store?
Which of these is the best fit for a shared store?
Nano Stores10 / 155
Module 1 · State, and why parts of a page share it
Recap: state and sharing
State is what a page remembers while it runs: the cart, the theme, what was typed.
When state changes, everything that shows it must update.
Passing values through widgets gets tangled, and is impossible without a common parent.
A global variable is shared, but changes to it reach nobody.
A store holds a value and notifies its subscribers: the observer pattern.
Nano Stores is a tiny, framework-free library of stores. Use it for state that several widgets share.
FlashcardsInteractive · disabled in the PDF
State
The values a page remembers while it runs, which change as it is used.
Widget (component)
A self-contained piece of a page with one job.
Shared state
State that several widgets read or change.
Why not a global variable?
Anyone can change it, and nobody is told when it changes.
Store
Holds a value, lets code change it, notifies subscribers of changes.
Observer pattern
Others subscribe to a value and are called back when it changes.
Library vs framework
A library is code you call; a framework shapes how you build the whole interface.
Module 2: Build a tiny store by hand
Nano Stores11 / 155
Module 2 · Build a tiny store by hand
Twenty lines of code, and you’ll understand the library
Before installing anything, you will write a tiny store yourself. It is the best way to see that Nano Stores isn’t magic: a store is a value, a list of people to call, and three small functions. The code is JavaScript, the language every browser runs.
Functions you can hand to other functions (callbacks).
Objects that group values and functions under names.
A store: a value, a list of listeners, get, set and subscribe.
How to stop listening, and why equal values shouldn’t notify.
Try it as you read. Every browser has a console: a box where you can type JavaScript and see the result. Open it with F12 (or Cmd+Option+J on a Mac), choose “Console”, and paste the examples in.
Prices in cents avoid rounding errors: computers add 0.1 + 0.2 to 0.30000000000000004.
Curly braces make an object: named slots called properties. A property can hold a function, then called a method: describe here. this inside a method means “the object it belongs to”. Square brackets make an array, an ordered list: ["mug", "plate"].
QuizInteractive · disabled in the PDF
Given the mug object above, what is mug.price / 100?
Given the mug object above, what is mug.price / 100?
Nano Stores14 / 155
Module 2 · Build a tiny store by hand
A store is a value plus a list of listeners
WalkthroughInteractive · disabled in the PDF
JavaScript
1functioncreateStore(initial){2let value = initial3const listeners =[]45return{6get(){7return value8},9set(newValue){10 value = newValue11for(const listener of listeners){12listener(value)13}14},15subscribe(listener){16 listeners.push(listener)17listener(value)18}19}20}
Lines 1:createStore makes a new store, starting from an initial value.
Lines 2-3: Two things are kept inside: the current value, and an array of listeners, the callbacks to notify.
Lines 6-8:get hands back the current value.
Lines 9-14:set replaces the value, then loops over every listener and calls it with the new value. That loop is the whole “notify” idea.
Lines 15-18:subscribe adds a listener to the list, and calls it once straight away so it can draw the current value.
Note.value and listeners can only be reached through the three methods: code outside can’t change the value without going through set, so it can’t forget to notify. The functions “remember” the variables around them; this is called a closure.
Compare with the global variable of module 1. The badge subscribes once. addToCart doesn’t know the badge exists: it only changes the store. A checkout panel added next month subscribes too, and addToCart doesn’t change.
Fill the blanksInteractive · disabled in the PDF
Finish set so every listener hears about the new value.
JavaScript
1set(newValue){2 value = newValue3for(const listener of){45}6}
Nano Stores16 / 155
Module 2 · Build a tiny store by hand
Subscribing should hand back a way to stop
When a widget is removed from the page, its listener should be removed too. Otherwise the store keeps calling a function for something that is gone, and keeps it in memory forever: a memory leak. The neat way: subscribe returns a function that unsubscribes.
JavaScript
1subscribe(listener){2 listeners.push(listener)3listener(value)4return()=>{5const index = listeners.indexOf(listener)6if(index !==-1) listeners.splice(index,1)7}8}910// using it11const stop = cartCount.subscribe(draw)12stop()// draw is never called again
indexOf finds the listener’s position; splice(index, 1) removes one item there.
Predict the outputInteractive · disabled in the PDF
1set(newValue){2if(Object.is(value, newValue))return3 value = newValue4for(const listener of listeners)listener(value)5}
If the theme is already "dark" and someone sets "dark", redrawing every widget is wasted work. Object.is(a, b) asks “are these the same value?”, and return leaves the function early. For numbers and text, “same” means equal. For objects and arrays it means the very same object, not one with the same contents: two arrays that look alike are still two arrays.
QuizInteractive · disabled in the PDF
Which of these is true?
Which of these is true?
Nano Stores18 / 155
Module 2 · Build a tiny store by hand
Try it: build the store yourself
Put the last three slides together. The tests below check each promise the store makes; run them as you go and watch them turn green one by one.
Code challengeInteractive · disabled in the PDF
Finish 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).
JavaScript
1functioncreateStore(initial){2let value = initial3const listeners =[]4return{5get(){6return value7},8set(newValue){9 value = newValue10},11subscribe(listener){12 listeners.push(listener)13return()=>{}14}15}16}
Nano Stores19 / 155
Module 2 · Build a tiny store by hand
What the real library adds
Your store works. Nano Stores’ atom is the same idea, carefully tested, with more on top.
Need
Nano Stores gives you
Module
Change one field of an object
map and setKey
5
Values worked out from others
computed
6
Use stores in React, Vue, Svelte…
Small binding packages
8, 9
Start work only when someone listens
onMount, lazy stores
10
Remember values between visits
@nanostores/persistent
11
Group several changes into one
batch, batched
14
The same shape inside. Nano Stores’ own atom keeps a value, an array of listeners, compares with Object.is on set, and returns an unsubscribe function from listen. What it adds is a queue so that listeners run in a predictable order even when a listener changes another store, and lifecycle hooks around the first and last listener.
Nano Stores20 / 155
Module 2 · Build a tiny store by hand
Recap: a store by hand
A function is a value: you can pass it to other code as a callback.
An object groups properties and methods under names; an array is an ordered list.
A store keeps a value and an array of listeners; set calls each listener.
subscribe should return a function that removes the listener, or memory leaks.
Skip notifying when Object.is says the value is the same.
Objects and arrays are “the same” only if they are the very same object.
FlashcardsInteractive · disabled in the PDF
Callback
A function given to other code, to be called later.
Arrow function
(inputs) => result: a short way to write a function.
Object, property, method
A group of named slots; a slot; a slot holding a function.
Array
An ordered list of values: ["mug", "plate"].
Closure
A function that remembers the variables around where it was made.
Memory leak
Something kept in memory after it is no longer needed, like a forgotten listener.
Object.is([1], [1])
false: two separately made arrays are different objects.
Module 3: Install it and make your first store
Nano Stores21 / 155
Module 3 · Install it and make your first store
The tools: a terminal, Node.js and npm
Web projects are set up with a few tools that run on your computer. You type commands into a terminal, a window where you talk to the computer in text. Node.js runs JavaScript outside the browser. npm comes with Node.js and downloads libraries, called packages, from a public registry.
Terminal
A text window for typing commands: Terminal on a Mac, Windows Terminal on Windows.
Node.js
A program that runs JavaScript on your computer or a server, not only in a browser.
npm
The package manager that comes with Node.js: it installs and updates libraries.
Package
A library published for npm, with a name like nanostores.
Terminal
1node--version# prints something like v22.11.02npm--version# prints something like 10.9.0
If both print a version, you are ready. If not, install Node.js from nodejs.org.
Nano Stores22 / 155
Module 3 · Install it and make your first store
npm install adds Nano Stores to a project
Terminal
1mkdirshop&&cdshop# make a folder and enter it2npminit-y# start a project: makes package.json3npminstallnanostores# download Nano Stores into it
Trimmed. "type": "module" lets Node.js use import: add it, or change "commonjs" to it.
package.json is the project’s list of what it needs. After npm install, Nano Stores appears under dependencies, and its code lands in a node_modules folder you never edit. ^1.0.0 means “version 1, any later 1.x release”. This course teaches Nano Stores 1.
Nano Stores23 / 155
Module 3 · Install it and make your first store
A module is a file that shares what it exports
stores/cart.js
exportconst greeting ='hi'exportfunctionadd(a, b){return a + b}
In modern JavaScript, each file is a module. export marks what other files may use; import brings it in. One fact matters a lot for stores: a module runs once. However many files import stores/cart.js, they all get the very same values. That is how separate widgets end up sharing one store.
QuizInteractive · disabled in the PDF
The badge and the checkout panel both import $cart from stores/cart.js. How many carts are there?
The badge and the checkout panel both import $cart from stores/cart.js. How many carts are there?
Nano Stores24 / 155
Module 3 · Install it and make your first store
Your first store: an atom
WalkthroughInteractive · disabled in the PDF
stores/theme.js
1import{ atom }from'nanostores'23exportconst $theme =atom('light')45$theme.get()// 'light'67$theme.subscribe((theme)=>{8 console.log('theme is now', theme)9})10// logs 'theme is now light' right away1112$theme.set('dark')13// logs 'theme is now dark'
Lines 1: Import atom from the package. With a package name (no ./), the import looks in node_modules.
Lines 3:atom(initial) makes a store holding one value, starting with "light". It is exported so other files can use it.
Lines 5:get() reads the current value.
Lines 7-10:subscribe registers a listener, calls it at once with the current value, and again on each change. Exactly like the store you built.
Lines 12-13:set replaces the value and notifies listeners.
Atom
Nano Stores’ basic store: it holds one value, replaced as a whole with set.
PlaygroundInteractive · disabled in the PDF
TypeScript
1import{ atom }from'nanostores'23const $count =atom(0)45$count.subscribe((count)=>{6 console.log('the badge shows', count)7})89$count.set(1)10$count.set($count.get()+1)11$count.set(2)
Three set calls, but how many lines after the first? Change the numbers and run it again.
Nano Stores25 / 155
Module 3 · Install it and make your first store
Store names start with $
Nano Stores code names stores with a $ in front: $theme, $cart. To JavaScript, $ is just a letter. To people, it says “this is a store: read it with get() or subscribe, don’t treat it as the value itself”.
Confusing
const theme =atom('light')if(theme ==='dark'){// never true: theme is a store}
Clear
const $theme =atom('light')if($theme.get()==='dark'){// compares the value}
Fill the blanksInteractive · disabled in the PDF
Make a store for whether the menu is open, starting closed, and open it.
TypeScript is JavaScript with types: labels that say what kind of value a name may hold. A checker reads them and points out mistakes before the code runs. Nano Stores is written for it. Files end in .ts instead of .js. The rest of this course uses TypeScript.
stores/theme.ts
1import{ atom }from'nanostores'23exporttypeTheme='light'|'dark'45exportconst $theme =atom<Theme>('light')67$theme.set('dark')// fine8$theme.set('blue')// error, see below
type Theme = "light" | "dark" names a type: one of these two words, nothing else. | reads “or”.
atom<Theme>(…) tells the atom the type of its value. The part in angle brackets is a type parameter.
Without it, atom("light") would accept any text at all.
Nano Stores27 / 155
Module 3 · Install it and make your first store
Reading a type error
Text
1stores/theme.ts:8:12 - error TS2345: Argument of type2'"blue"' is not assignable to parameter of type 'Theme'.
stores/theme.ts:8:12: the file, the line (8) and the column (12). Go there first.
Argument of type "blue": the value you passed.
is not assignable to parameter of type Theme: what was expected. The two don’t fit.
Fix the value, or, if "blue" should be allowed, add it to Theme.
Find the bugInteractive · disabled in the PDF
This store should hold the signed-in user’s name, or null when nobody is signed in. Which line makes set("Ana") a type error?
TypeScript
Nano Stores28 / 155
Module 3 · Install it and make your first store
Try it on your computer
main.ts
1import{ atom }from'nanostores'23const $count =atom(0)4$count.subscribe((n)=> console.log('count', n))5$count.set(1)6$count.set(2)
Terminal
1npxtsxmain.ts2# count 03# count 14# count 2
npx runs a tool without installing it for good; tsx runs a TypeScript file directly.
Try itInteractive · disabled in the PDF
Nano Stores29 / 155
Module 3 · Install it and make your first store
Recap: first store
npm installs packages; package.json lists them; their code lives in node_modules.
Each file is a module that runs once: everything importing a store shares it.
atom(initial) makes a store; get, set and subscribe use it.
Name stores with $ so they are never mistaken for their values.
In TypeScript, give the atom its type when the first value is narrower: atom<Theme>("light").
A type error names the file, line and column, the value given and the type expected.
FlashcardsInteractive · disabled in the PDF
npm install nanostores
Downloads the package and lists it in package.json.
Module
A file with exports; it runs once, and every importer shares its values.
atom(initial)
A store holding one value, replaced whole with set.
Why $theme?
A convention marking a store, so nobody compares the store itself to a value.
TypeScript
JavaScript with types, checked before the code runs.
atom<string | null>(null)
A store that starts null but may later hold text.
Module 4: Atoms in depth
Nano Stores30 / 155
Module 4 · Atoms in depth
The cart as an atom
stores/cart.ts
1import{ atom }from'nanostores'23exporttypeCartItem={4 id:string5 name:string6 price:number// in cents7 quantity:number8}910exportconst $cart =atom<CartItem[]>([])
type CartItem = { … } describes an object: every cart item has these four properties, of these types.
CartItem[] means “an array of CartItems”.
The cart starts as an empty array, []. Without <CartItem[]>, TypeScript couldn’t know what goes in it.
This store is the running example for the rest of the course: a header badge, a product list and a checkout panel will all use it.
Nano Stores31 / 155
Module 4 · Atoms in depth
set() notifies only when the value really changed
Like the store you built, set compares the new value with the current one using Object.is. Equal: nothing happens, no listener runs. For text and numbers that means the same value; for objects and arrays, the same object.
Predict the outputInteractive · disabled in the PDF
Predict the lines before you press Run. Then swap the order of subscribe and listen.
Nano Stores33 / 155
Module 4 · Atoms in depth
Both return a function that stops listening
TypeScript
1const stop = $cart.listen((cart)=>{2 console.log('items:', cart.length)3})45// when the widget is removed from the page:6stop()
A listener you forget to remove keeps running, and keeps everything it uses in memory, for as long as the page is open. Framework bindings (module 8) call stop for you when a component goes away; in a plain script, it is your job. Calling stop twice is harmless.
Fill the blanksInteractive · disabled in the PDF
Log theme changes until the settings panel closes.
TypeScript
1const stop = $theme.((theme, previous)=>{2 console.log(`from ${previous} to ${theme}`)3})45$theme.('dark')// logs 'from light to dark'67// when the panel closes:8
Nano Stores34 / 155
Module 4 · Atoms in depth
Replace values; never change them in place
Arrays and objects can be changed in place: cart.push(item) adds to the same array. But set compares objects by identity, so setting the same array back looks like “no change” and notifies nobody. The rule: always build a new array or object.
Changes in place
const cart = $cart.get()cart.push(mug)$cart.set(cart)// same array: nobody is told
Builds a new array
$cart.set([...$cart.get(), mug])// a new array: listeners run
Note.... is the spread syntax: [...list, item] makes a new array with everything in list, then item. { ...item, quantity: 3 } makes a new object with all of item’s properties, and quantity replaced.
Nano Stores35 / 155
Module 4 · Atoms in depth
Find the change in place
Find the bugInteractive · disabled in the PDF
Clicking “Sort by name” leaves the checkout panel unchanged. Which line is wrong?
TypeScript
Readonly types catch it early. Nano Stores types an object’s value as Readonly when you read it from a listener, so TypeScript already complains about some in-place changes. What get() returns isn’t covered, and Readonly is only one level deep: the habit of copying is still yours.
Nano Stores36 / 155
Module 4 · Atoms in depth
Try it: add and remove without changing in place
Code challengeInteractive · disabled in the PDF
Fix addItem and removeItem so they set a new array: [...cart, item] to add, filter to remove the item with that id.
TypeScript
1import{ atom }from'nanostores'23typeItem={ id:string; name:string}45exportconst $cart =atom<Item[]>([])67exportfunctionaddItem(item:Item){8 $cart.get().push(item)9}1011exportfunctionremoveItem(id:string){12const cart = $cart.get()13const index = cart.findIndex((item)=> item.id === id)14if(index !==-1) cart.splice(index,1)15}
Nano Stores37 / 155
Module 4 · Atoms in depth
Store types: writable, readable, and the value inside
A helper that only reads should accept ReadableAtom: any store fits, and it can’t write.
QuizInteractive · disabled in the PDF
Stores also have a .value property. Why use get()?
Stores also have a .value property. Why use get()?
Nano Stores38 / 155
Module 4 · Atoms in depth
Recap: atoms
atom<T>(initial) holds one value; give T when the starting value is narrower.
set notifies only when Object.is says the value changed.
subscribe runs at once and on changes; listen only on changes, with the old value.
Both return a function that stops listening. Call it when the reader goes away.
Never change a store’s array or object in place: spread, map, filter, toSorted.
Accept ReadableAtom<T> where you only read; read with get(), not .value.
FlashcardsInteractive · disabled in the PDF
When does set notify?
When the new value isn’t Object.is-equal to the current one.
subscribe vs listen
subscribe calls back at once and on changes; listen only on changes, with the old value.
Why does push then set notify nobody?
It is the same array, so set sees no change.
Spread ...
Copies an array’s items or an object’s properties into a new one.
ReadableAtom<T>
A store you can read and listen to but not set.
StoreValue<typeof $s>
The type of the value inside $s.
Module 5: Maps: objects changed key by key
Nano Stores39 / 155
Module 5 · Maps: objects changed key by key
Some state is a group of named fields
The visitor’s preferences are a small group: a theme, a currency, whether prices include tax. With an atom, changing one field means copying the whole object every time. A map store is made for this: an object whose fields you change one key at a time.
An atom
$prefs.set({...$prefs.get(),theme:'dark'})
A map
$prefs.setKey('theme','dark')
Key
The name of a property in an object: in { theme: "dark" }, the key is theme.
Map store
A Nano Store holding an object, with setKey to change one key.
Lines 3-7: The type of the object. withTax?: with a question mark means the key is optional: it may be missing.
Lines 9-12:map<Prefs>(initial) makes the store, starting with two keys.
Lines 14:setKey changes one key. The map builds a new object with the change, so the “new object means changed” rule still holds.
Lines 15: A map is still an atom: set replaces the whole object, get, listen and subscribe work as before.
Nano Stores41 / 155
Module 5 · Maps: objects changed key by key
Equal values don’t notify; undefined removes a key
setKey("theme", "dark") when the theme is already "dark" notifies nobody: each key is compared with Object.is.
setKey("withTax", undefined)deletes the key from the object, and notifies (if the key was there).
Listeners get a third argument: the key that changed. After a whole-object set it is undefined.
TypeScript
1$prefs.listen((prefs, oldPrefs, changedKey)=>{2 console.log(changedKey,'is now', changedKey && prefs[changedKey])3})45$prefs.setKey('currency','eur')// currency is now eur6$prefs.setKey('currency','eur')// nothing7$prefs.setKey('withTax',true)// withTax is now true
QuizInteractive · disabled in the PDF
After $prefs.setKey("withTax", undefined), what is "withTax" in $prefs.get()?
After $prefs.setKey("withTax", undefined), what is "withTax" in $prefs.get()?
Nano Stores42 / 155
Module 5 · Maps: objects changed key by key
A cart keyed by product id
A map can also hold a collection keyed by id. Keyed this way, the cart can find, change or remove one product without searching an array. TypeScript writes “an object with any text keys, each a number” as Record<string, number>.
stores/cart.ts
1import{ map }from'nanostores'23/** Quantity of each product in the cart, by product id. */4exportconst $quantities =map<Record<string,number>>({})56exportfunctionaddOne(productId:string){7const now = $quantities.get()[productId]??08 $quantities.setKey(productId, now +1)9}1011exportfunctionremoveAll(productId:string){12 $quantities.setKey(productId,undefined)13}
a ?? b gives a, or b when a is null or undefined: a missing product counts as 0.
Fill the blanksInteractive · disabled in the PDF
Write removeOne: take one away, and drop the product when it reaches zero.
TypeScript
1exportfunctionremoveOne(productId:string){2const now = $quantities.get()[productId]??03if(now <=1){4 $quantities.setKey(productId,)5}else{6 $quantities.(productId,)7}8}
Nano Stores43 / 155
Module 5 · Maps: objects changed key by key
Try it: a cart keyed by id
Code challengeInteractive · disabled in the PDF
Write 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.
1import{ listenKeys, subscribeKeys }from'nanostores'23listenKeys($prefs,['theme'],(prefs)=>{4 document.documentElement.dataset.theme = prefs.theme5})67subscribeKeys($prefs,['theme'],(prefs)=>{8// the same, and called once right away too9})
dataset.theme sets the page’s data-theme attribute, which CSS can style.
Predict the outputInteractive · disabled in the PDF
A map only reaches its top-level keys. deepMap accepts a path written with dots (and [0] for array positions) to change a value deep inside. Each change copies the objects along the path, so references still say what changed.
On its way out of the core. In recent 1.x versions, deepMap from nanostores prints a warning in development: it is moving to a separate package, @nanostores/deepmap, and is to be removed from the core in 2.0. Prefer flat maps: often two small stores ($address, $card) are clearer than one deep one, and easier to type, compare and save.
Nano Stores47 / 155
Module 5 · Maps: objects changed key by key
Recap: maps
map(initial) is an atom holding an object, with setKey(key, value) on top.
Each setKey makes a new object; an equal value notifies nobody; undefined deletes the key.
Listeners get the changed key as a third argument (undefined after a whole set).
listenKeys and subscribeKeys react only to the keys you name.
Use a map for fields of one thing; separate atoms for unrelated values.
deepMap changes nested values by path; prefer flat stores when you can.
FlashcardsInteractive · disabled in the PDF
map().setKey(k, v)
Sets one key in a new object; equal values don’t notify.
setKey(k, undefined)
Removes the key from the map’s object.
listenKeys(store, keys, cb)
Listen to a map, but only for changes to the given keys.
subscribeKeys
Like listenKeys, plus one call right away.
Record<string, number>
The type of an object with any text keys, each holding a number.
deepMap
A map whose setKey takes a dotted path into nested objects.
Module 6: Computed stores: values worked out from others
Nano Stores48 / 155
Module 6 · Computed stores: values worked out from others
Some state is worked out, not stored
The badge shows how many items are in the cart. The checkout panel shows the total price. Neither is new information: both can be worked out from the cart at any moment. Values worked out from other state are called derived state.
Store the count too
Every action that changes the cart must also update the count.
Forget once, and the badge disagrees with the panel.
Derive the count
The count is worked out from the cart whenever the cart changes.
There is nothing to keep in sync, so it can’t go stale.
Derived state
A value worked out from other state, like a total from a list of prices.
Stale
Out of date: a copy that no longer matches what it was copied from.
Nano Stores49 / 155
Module 6 · Computed stores: values worked out from others
Lines 3: The cart stays the one real source of truth.
Lines 5-7:computed(source, fn) makes a store whose value is fn(source’s value). reduce walks the array, adding each quantity to a running sum that starts at 0.
Lines 9-11: The total, in cents: price times quantity, added up. Both stores update by themselves when $cart changes.
A computed store is read-only: get, listen, subscribe, but no set. Its type is ReadableAtom.
Readers use it like any other store; they can’t tell it is derived.
Nano Stores50 / 155
Module 6 · Computed stores: values worked out from others
Derive from several stores at once
TypeScript
1const RATES ={usd:1,eur:0.9}// example rates23exportconst $shownTotal =computed(4[$total, $prefs],5(total, prefs)=>{6const amount =(total /100)* RATES[prefs.currency]7return amount.toFixed(2)+' '+ prefs.currency.toUpperCase()8}9)10// "24.00 USD", or "21.60 EUR" after switching
toFixed(2) turns a number into text with two decimals.
Pass an array of stores, and the function receives their values in the same order. The result updates when any of them changes. A computed store can be a source for another computed store too: $shownTotal is built on $total, itself built on $cart.
Fill the blanksInteractive · disabled in the PDF
Make $freeShipping: true when the cart’s total is at least 50.00.
TypeScript
1exportconst $freeShipping =(2,3(total)=> total 50004)
Nano Stores51 / 155
Module 6 · Computed stores: values worked out from others
Try it: a total with a discount
Code challengeInteractive · disabled in the PDF
Make $total a computed store from $cart and $discount: the sum of price * quantity, minus $discount percent, rounded to whole cents with Math.round.
1import{ atom, computed }from'nanostores'23const $cart =atom([1200,350])4const $total =computed($cart,(prices)=>{5 console.log('working out the total')6return prices.reduce((sum, price)=> sum + price,0)7})89$cart.set([1200])// nobody watches: nothing is worked out10console.log('first get:', $total.get())1112const stop = $total.listen((total)=> console.log('total', total))13$cart.set([1200,99])14$cart.set([1200,99,1])15stop()
Count the "working out" lines. Then remove the listen and run it again.
Nano Stores53 / 155
Module 6 · Computed stores: values worked out from others
An unchanged result notifies nobody
A computed store is an atom inside, so its own result goes through the same Object.is check. If the cart changes but $freeShipping is still true, nobody listening to $freeShipping is told. That makes small computed values a cheap filter: widgets that only need a yes or no re-render only when the answer flips.
QuizInteractive · disabled in the PDF
The cart total goes 6000 → 7000 → 4000 cents. How many times are $freeShipping’s listeners called?
The cart total goes 6000 → 7000 → 4000 cents. How many times are $freeShipping’s listeners called?
Nano Stores54 / 155
Module 6 · Computed stores: values worked out from others
The function must be pure
A pure function only reads its inputs and returns a result. It doesn’t change anything outside, doesn’t fetch, doesn’t read the clock. Nano Stores decides when to run your function (on first listener, on source changes, on get()), so anything else it does happens at times you don’t control.
Find the bugInteractive · disabled in the PDF
Analytics receives cart totals at odd moments, sometimes twice, sometimes never. Which line is the mistake?
TypeScript
No async work in computed. Async computed, where the function returns a promise made with task() (module 12), is deprecated in Nano Stores 1: it warns in development and is to be removed in 2.0. A plain async function doesn’t work at all: the store’s value becomes the promise itself. Load data in a lifecycle hook or an action (modules 10 and 12), or use @nanostores/async or @nanostores/query.
Nano Stores55 / 155
Module 6 · Computed stores: values worked out from others
Derive, don’t duplicate
Find the bugInteractive · disabled in the PDF
After the visitor switches currency, the total is right. After they add an item, it is the old total. Which line is the design error?
TypeScript
Nano Stores56 / 155
Module 6 · Computed stores: values worked out from others
Recap: computed stores
Derived state is worked out from other state: never store a copy you must keep in sync.
computed(source, fn) or computed([a, b], fn) makes a read-only store.
It is lazy: it works only while listened to, and get() computes on demand.
Each source change recomputes it synchronously; an unchanged result notifies nobody.
Computed stores can build on each other.
Keep the function pure and synchronous: no fetching, sending or setting stores.
FlashcardsInteractive · disabled in the PDF
computed(stores, fn)
A read-only store whose value is fn of other stores’ values.
Derived state
A value worked out from other state rather than stored.
Lazy store
One that does its work only while something listens.
Pure function
Reads its inputs, returns a result, changes nothing else.
Side effect
Something a function does to the outside world: fetching, sending, logging, setting.
When does a computed store notify?
When a source changed and its new result isn’t Object.is the old one.
Module 7: Actions: the logic lives beside the store
Nano Stores57 / 155
Module 7 · Actions: the logic lives beside the store
An action is a function that changes a store
Until now, code called $cart.set(…) wherever it liked. On a real page that spreads the rules of the cart across every widget: one adds items one way, another a slightly different way. An action is a plain function, exported next to the store, that makes one kind of change. Widgets call the action; only actions call set.
Module 7 · Actions: the logic lives beside the store
Actions read the current value with get()
Inside an action, get() reads the store at the moment the action runs, which is what you want: the cart as it is now, not as it was when a widget last drew. Actions can read several stores and change several too.
A widget saved const cart = $cart.get() when it first drew, and later calls $cart.set([...cart, item]). What goes wrong?
A widget saved const cart = $cart.get() when it first drew, and later calls $cart.set([...cart, item]). What goes wrong?
Nano Stores60 / 155
Module 7 · Actions: the logic lives beside the store
Try it: a to-do list with rules
The rules of a to-do list belong in its actions, so every widget that adds a task gets them for free.
Code challengeInteractive · disabled in the PDF
Finish 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.
TypeScript
1import{ atom }from'nanostores'23typeTodo={ id:number; text:string; done:boolean}45exportconst $todos =atom<Todo[]>([])6let lastId =07constnextId=()=>++lastId89exportfunctionaddTodo(text:string){10const todo ={id:nextId(), text,done:false}11 $todos.set([...$todos.get(), todo])12}1314exportfunctiontoggleTodo(id:number){15// TODO16}
Nano Stores61 / 155
Module 7 · Actions: the logic lives beside the store
Many small stores, one file per topic
src/
stores/
cart.ts# $cart, $itemCount, $total, cart actions
prefs.ts# $prefs, setTheme, setCurrency
menu.ts# $menuOpen, openMenu, closeMenu
widgets/
CartBadge.tsx
ProductList.tsx
CheckoutPanel.tsx
A store module holds the stores of one topic, their derived stores and their actions.
Small stores: one per piece of state, so readers are told only about what they show.
One module per topic: everything about the cart in cart.ts.
Widgets import, never define: a store made inside a widget file is easy to duplicate by mistake.
Stores can use other modules’ stores: checkout can read $prefs from prefs.ts.
Nano Stores62 / 155
Module 7 · Actions: the logic lives beside the store
readonlyType returns the very same store, but typed as a ReadableAtom. Outside the file, $cart.set(…) is a type error, so every change goes through the actions. It is a fence for TypeScript, not at run time: the object still has set. That is all a team needs.
QuizInteractive · disabled in the PDF
A plain JavaScript file (no type checking) calls $cart.set([]) on the exported store. What happens?
A plain JavaScript file (no type checking) calls $cart.set([]) on the exported store. What happens?
Nano Stores63 / 155
Module 7 · Actions: the logic lives beside the store
Widgets should read like a description of the page: “when clicked, add to cart”. Names about storage leak how the store works, and must change when it does. Good names also make the module’s exports a short, readable list of everything the cart can do.
Put in orderInteractive · disabled in the PDF
Put the steps of a good action in order.
1.Call set (or setKey) on the store
2.Read the current values with get()
3.Check the rules and stop early if the change isn’t allowed
4.Build the new value without changing the old one
Nano Stores64 / 155
Module 7 · Actions: the logic lives beside the store
Recap: actions
An action is an exported function beside a store that makes one named change.
Widgets call actions; only actions call set. The rules live in one place.
Actions read with get() at the moment they run.
Small stores, one module per topic; widgets import stores, never define them.
readonlyType exports a store that TypeScript won’t let others set.
Name actions for what happens: addToCart, not setCart.
FlashcardsInteractive · disabled in the PDF
Action
A function beside a store that changes it in one named way.
Why actions?
Rules live in one place; widgets stay simple; tests call functions.
readonlyType($store)
The same store, typed so that others can’t call set.
Store module
One file per topic: its stores, derived stores and actions.
Why read with get() inside the action?
To build on the value as it is now, not an old copy.
Lines 1: The React binding is a separate, tiny package. Version 2 needs React 18 or later and Nano Stores 1.2 or later.
Lines 2: Import the store from its module, like any other value.
Lines 5:useStore subscribes the component, returns the current value, and re-renders the component whenever the value changes.
Lines 6-7: Returning null shows nothing: no badge on an empty cart.
When the component is removed from the page, useStore unsubscribes for you. Under the hood it uses React’s useSyncExternalStore, the hook React provides for reading stores that live outside it.
This component doesn’t read the cart at all, so it needs no useStore: it never re-renders when the cart changes. It only calls an action. Meanwhile the badge, which does read the cart, updates. The two components don’t know about each other.
QuizInteractive · disabled in the PDF
The visitor clicks “Add”. Which components re-render?
The visitor clicks “Add”. Which components re-render?
Nano Stores68 / 155
Module 8 · Stores in React components
A component can read several stores
widgets/CheckoutPanel.tsx
1exportfunctionCheckoutPanel(){2const cart =useStore($cart)3const total =useStore($shownTotal)4if(cart.length ===0)return<p>Your cart is empty.</p>5return(6<section>7<ul>8{cart.map((item)=>(9<likey={item.id}>10{item.name} × {item.quantity}11<buttononClick={()=>removeFromCart(item.id)}>12 Remove13</button>14</li>15))}16</ul>17<p>Total: {total}</p>18<buttononClick={checkout}>Pay</button>19</section>20)21}
Fill the blanksInteractive · disabled in the PDF
Make a theme toggle that reads the preference and switches it through an action.
TSX
1import{}from'@nanostores/react'2import{ $prefs, setTheme }from'../stores/prefs'34exportfunctionThemeToggle(){5const{ theme }=useStore()6const next = theme ==='dark'?'light':'dark'7return(8<buttononClick={()=>}>9 Switch to {next}10</button>11)12}
Without keys, the button re-renders on every keystroke in the email field, because the map’s object changes. With keys: ["busy"], it is told only when busy changes. The value returned is still the whole object; only the notifications are filtered.
QuizInteractive · disabled in the PDF
The visitor types 12 letters of their email, then submits (busy becomes true). How many times does SubmitButton re-render after its first render?
The visitor types 12 letters of their email, then submits (busy becomes true). How many times does SubmitButton re-render after its first render?
Nano Stores70 / 155
Module 8 · Stores in React components
Work out values in stores, not in components
In every component
const cart =useStore($cart)const count = cart.reduce((sum, item)=> sum + item.quantity,0)
Once, in a computed store
const count =useStore($itemCount)
Say a product’s price is updated in the cart. $cart changes, but the number of items doesn’t. A component reading $itemCount stays still, because the computed result is the same number. One that reads $cart and counts by itself re-renders for nothing.
Nano Stores71 / 155
Module 8 · Stores in React components
Nano Store, React state, or React context?
Tool
Shared with
Pick it for
useState
One component (and what it passes down)
An open dropdown, a field being typed
React context
Components inside one provider
Values for a subtree, inside one React app
Nano Store
Any code that imports it, React or not
State shared across the page, with logic beside it
QuizInteractive · disabled in the PDF
A tooltip opens when its own icon is hovered. Nothing else cares. Where should “open” live?
A tooltip opens when its own icon is hovered. Nothing else cares. Where should “open” live?
Nano Stores72 / 155
Module 8 · Stores in React components
Recap: React
npm install @nanostores/react; useStore($store) subscribes and returns the value.
The component re-renders when the value changes, and unsubscribes when removed.
Components that only call actions don’t need useStore, and don’t re-render.
useStore($map, { keys: [...] }) re-renders only for those keys.
Compute in computed stores, not in components.
One component’s state stays in useState; shared state goes to a store.
FlashcardsInteractive · disabled in the PDF
useStore($store)
Subscribes a React component to a store and returns its value.
What makes a component re-render?
A store it reads with useStore getting a new value (or its own state changing).
useStore($map, { keys })
Re-render only when one of those keys changes.
JSX
HTML-like tags written inside TypeScript, in .tsx files.
useSyncExternalStore
The React hook that useStore is built on, for stores outside React.
Module 9: Islands and other frameworks
Nano Stores73 / 155
Module 9 · Islands and other frameworks
An island is an interactive widget in a static page
Many sites are mostly plain HTML, with a few interactive widgets dropped in: a cart badge in the header, an “Add” button by each product, a checkout panel. Each widget is an island: it is sent as HTML, then started on its own in the browser. Astro builds pages this way, and so do other tools.
Three islands, each its own React app, in a sea of static HTML.
Island
An interactive widget, started on its own, inside a page that is otherwise static HTML.
Hydration
A framework taking over HTML that was already drawn: attaching click handlers and state to it so it becomes interactive.
Nano Stores74 / 155
Module 9 · Islands and other frameworks
Islands have no common parent, but they share modules
Each island is a separate React root: its own tree, started on its own. React state and React context don’t cross from one root to another, because there is no component above them both. What they do share is the page’s JavaScript: when two islands import stores/cart.ts, the module runs once and both get the same $cart.
The store module is the meeting point: one instance per page.
QuizInteractive · disabled in the PDF
Why can’t the badge and the list share the cart through React context?
Why can’t the badge and the list share the cart through React context?
This script lives as long as the page, so it never needs to stop listening.
Not every interactive bit needs a framework. A script that sets the page’s theme attribute can import the same store module as the React islands. When the theme toggle island changes $prefs, this script hears it too.
QuizInteractive · disabled in the PDF
A plain script and a React island both import $prefs. The island calls setTheme("dark"). Does the script’s listener run?
A plain script and a React island both import $prefs. The island calls setTheme("dark"). Does the script’s listener run?
Nano Stores77 / 155
Module 9 · Islands and other frameworks
Vue, Svelte, Preact, Solid: same store, small bindings
Framework
Package
In a component
React
@nanostores/react
useStore($cart) returns the value
Preact
@nanostores/preact
useStore($cart) returns the value
Vue
@nanostores/vue
useStore($cart) returns a read-only ref
Solid
@nanostores/solid
useStore($cart) returns an accessor to call
Svelte
none needed
Stores fit Svelte’s store contract: $store syntax
A Vue component’s script
1import{ useStore }from'@nanostores/vue'2import{ $itemCount }from'../stores/cart'34const count =useStore($itemCount)5// in the template: {{ count }}
Mixing frameworks on one page. Because stores are framework-free, a Vue island and a React island can share $cart. Each uses its own binding; the store doesn’t care who reads it.
Nano Stores78 / 155
Module 9 · Islands and other frameworks
Svelte reads stores directly
CartBadge.svelte
1<script>2import{ $itemCount as itemCount }from'../stores/cart'3</script>45{#if $itemCount > 0}6<spanclass="badge">{$itemCount}</span>7{/if}
Svelte has its own idea of a store: anything with a subscribe that calls back at once and returns an unsubscribe function. Nano Stores fits. In a Svelte component, writing $ before a store’s name reads it and keeps it up to date. Since our stores’ names already start with $, import them under a plain name first.
Nano Stores79 / 155
Module 9 · Islands and other frameworks
Islands are drawn on the server first
Before an island starts in the browser, its HTML was made on the server (or at build time), using the store’s starting value: there is no visitor there, no saved cart. In the browser, the store may already hold something else, for example a cart saved from an earlier visit (module 11). Then the island’s first render in the browser differs from the HTML it is taking over: a hydration mismatch.
Watch out. On a mismatch, React reports an error and throws away the server’s HTML for that island, drawing it again from scratch. It costs time and can flicker.
Nano Stores80 / 155
Module 9 · Islands and other frameworks
Hydrate with the starting value, then catch up
TSX
1const cart =useStore($cart,{ssr:'initial'})
With ssr: "initial", useStore hands React the value the store was created with for the server render and for hydration, so they match. Right after, React renders again with the live value. The visitor sees the badge appear one frame later; React sees no mismatch. That only works when the store is created with the same value on the server and in the browser. A store created from something only the browser has (the URL, a value saved by @nanostores/persistent) needs a function instead, ssr: () => value, returning what the server drew.
QuizInteractive · disabled in the PDF
$itemCount is a computed store. What does useStore($itemCount, { ssr: "initial" }) give during hydration?
$itemCount is a computed store. What does useStore($itemCount, { ssr: "initial" }) give during hydration?
Nano Stores81 / 155
Module 9 · Islands and other frameworks
One page, one copy of each store module
Sharing works because the module runs once. If two widgets are built separately, each bundling its own copy of stores/cart.ts, the page has two carts that never hear each other. Tools like Astro build one page’s islands together, so shared modules are shared. Watch out when widgets come from separately built packages.
Two copies of the library. Two copies of nanostores itself (say, two versions installed) also mean two sets of internals. Recent 1.x versions keep their change counter on globalThis so mixed copies cooperate better, but the cure is one version: check with npm ls nanostores.
Nano Stores82 / 155
Module 9 · Islands and other frameworks
Recap: islands
An island is a widget started on its own; islands share no React tree, state or context.
They share modules: one store module, one store, every island.
Plain scripts, Vue, Svelte, Preact and Solid use the same stores through small bindings.
Server HTML is built with stores’ starting values; a different first render is a hydration mismatch.
useStore($store, { ssr: "initial" }), or ssr: () => value, hydrates with what the server drew, then catches up.
Make sure the page loads one copy of each store module.
FlashcardsInteractive · disabled in the PDF
Island
An interactive widget started on its own inside static HTML.
Hydration
A framework taking over HTML already drawn, to make it interactive.
Hydration mismatch
The first browser render differs from the server’s HTML.
ssr: "initial"
Render the value the store was created with, on the server and while hydrating. Right only if both sides create it with the same value.
Svelte and Nano Stores
Stores fit Svelte’s contract; read them with $name in a component.
Two carts on one page?
The store module was bundled twice; each copy made its own store.
Module 10: Lazy stores and their lifecycle
Nano Stores83 / 155
Module 10 · Lazy stores and their lifecycle
A store with no listeners is asleep
Every store is either mounted (it has at least one listener) or not (it has none). onMount runs a function when a store gets its first listener, and the function that one returns runs when the last listener leaves. So a store can watch the network, a timer or the window only while something on the page actually uses it.
Work starts once however many widgets listen, and stops when they have all gone.
Mounted store
A store that has at least one listener, so its lifecycle work is running.
`onMount`
Runs a function on a store’s first listener; what it returns runs after the last one leaves.
Lines 3: The store starts with a safe guess, true.
Lines 5:onMount(store, fn) registers fn. Nothing runs yet: no listener so far.
Lines 6-7: On the first listener: read the real value. navigator.onLine is the browser’s answer.
Lines 8-9: Then ask the browser to call update whenever the connection comes or goes. An event listener is a callback the browser calls when something happens.
Lines 10-13: The returned function undoes exactly what was set up. It runs once nobody listens any more.
Tip. Because nothing runs until something listens, a page that never shows the online badge never watches the network. Pages pay only for the stores they use.
Nano Stores85 / 155
Module 10 · Lazy stores and their lifecycle
Try it: open a feed only while someone listens
A shop shows live stock levels from a feed. The feed costs something to keep open, so the store opens it when the first widget listens and closes it when the last one leaves.
PlaygroundInteractive · disabled in the PDF
TypeScript
1import{ atom, onMount }from'nanostores'23const $stock =atom(0)4onMount($stock,()=>{5 console.log('mounted: open the feed')6return()=> console.log('unmounted: close the feed')7})89const stopA = $stock.listen(()=>{})10const stopB = $stock.listen(()=>{})11stopA()12stopB()13console.log('last listener gone')
The cleanup line appears a second after the last one: the delay from the next slide.
Code challengeInteractive · disabled in the PDF
In createStock(feed), use onMount on $stock so it calls feed.open() when it mounts, and returns a cleanup that calls feed.close().
The cleanup doesn’t run the instant the last listener leaves. It waits STORE_UNMOUNT_DELAY, 1000 milliseconds, and is skipped if a new listener arrives meanwhile. A component that disappears and reappears straight away (switching tabs in a panel, say) doesn’t restart the store’s work.
QuizInteractive · disabled in the PDF
The only listener of $online unsubscribes. 300 ms later, another component subscribes. How many times has onMount’s function run in total?
The only listener of $online unsubscribes. 300 ms later, another component subscribes. How many times has onMount’s function run in total?
Nano Stores87 / 155
Module 10 · Lazy stores and their lifecycle
get() on a sleeping store wakes it, briefly
To give a correct value, get() on a store with no listeners adds a listener and removes it straight away. That counts as a first listener: onMount runs, then its cleanup is scheduled a second later. Useful for computed stores, which need their sources to compute. Surprising if onMount starts expensive work.
Predict the outputInteractive · disabled in the PDF
effect (new in 1.0) runs its function at once, and again after any change to its stores. The function may return a cleanup, which runs before the next run and when you call stop(). While the effect is active, it counts as a listener: it keeps its stores mounted.
QuizInteractive · disabled in the PDF
With the effect above, the visitor goes offline, then switches currency. How many times has the function run, counting the first?
With the effect above, the visitor goes offline, then switches currency. How many times has the function run, counting the first?
Nano Stores91 / 155
Module 10 · Lazy stores and their lifecycle
Recap: lifecycle
A store is mounted while it has listeners; onMount runs on the first.
The function onMount returns cleans up, STORE_UNMOUNT_DELAY (1 s) after the last leaves.
get() on a sleeping store mounts it briefly: onMount runs, cleanup follows a second later.
onSet checks or refuses a change; onNotify, onStart, onStop hook other moments.
effect(stores, fn) runs now and on changes, with cleanup, and keeps stores mounted.
FlashcardsInteractive · disabled in the PDF
onMount(store, fn)
fn runs on the first listener; what it returns runs after the last one leaves.
STORE_UNMOUNT_DELAY
1000 ms: how long cleanup waits, cancelled if a listener returns.
Does get() mount a store?
Briefly: it listens and unlistens, so onMount runs and cleanup is scheduled.
onSet and abort()
See a value before it is stored; abort() refuses it.
effect(stores, fn)
Runs fn now and after each change, with an optional cleanup.
Event listener
A callback the browser calls when something happens: a click, going offline.
Try itInteractive · disabled in the PDF
Module 11: Remembering state between visits
Nano Stores92 / 155
Module 11 · Remembering state between visits
localStorage keeps text in the browser
Stores live in memory: reload the page and the cart is empty again. Browsers offer localStorage, a small box of saved text per website. It survives reloads and restarts, and every tab of the same site sees the same box.
Lines 1: A separate package; version 1 works with Nano Stores 1.
Lines 5: The first argument is the localStorage key, the second the value to use when nothing is saved yet.
Lines 5: The saved value is read when the store is created: on a return visit, $theme starts as "dark" straight away.
Lines 7-9: It is a normal atom. set also writes the new value to localStorage.
Note. If the browser blocks storage (some private modes do), the package falls back to keeping values in memory: the page works, it just forgets on reload.
Nano Stores94 / 155
Module 11 · Remembering state between visits
Anything but text needs encoding
localStorage only holds text. A number, a boolean or a list must be turned into text when saved (encoded) and back when read (decoded). TypeScript only lets you skip encode and decode for text values.
1import{ persistentMap }from'@nanostores/persistent'23exportconst $settings =persistentMap<{4 theme:'light'|'dark'5 currency:'usd'|'eur'6}>('settings:',{theme:'light',currency:'usd'})78$settings.setKey('theme','dark')9// localStorage now has 'settings:theme' = 'dark'
The first argument is a prefix: each field is saved under its own key, settings:theme, settings:currency. Changing one field rewrites one key. Without encode and decode, every field must be text; with them, every field goes through the same pair.
QuizInteractive · disabled in the PDF
After $settings.setKey("currency", undefined), what is in localStorage for the currency?
After $settings.setKey("currency", undefined), what is in localStorage for the currency?
Nano Stores97 / 155
Module 11 · Remembering state between visits
Tabs stay in sync, while the store is mounted
Change the theme in one tab, and the browser fires a storage event in the other tabs of the same site. A mounted persistent store listens for it and updates, so its widgets redraw. It also listens for pageshow, which fires when the browser restores a page from its back-forward cache, and re-reads anything that changed meanwhile.
TypeScript
1// keep this tab to itself2exportconst $draft =persistentAtom('draft','',{listen:false})
QuizInteractive · disabled in the PDF
In tab B nothing listens to $theme. Tab A switches to dark. Then a widget in tab B subscribes. What does it see?
In tab B nothing listens to $theme. Tab A switches to dark. Then a widget in tab B subscribes. What does it see?
Nano Stores98 / 155
Module 11 · Remembering state between visits
Saved values are user input: check them
Anything in localStorage may be stale (saved by an older version of your site), foreign (written by another script on the site) or edited by hand in the browser’s tools. Treat it like text typed into a form: accept only values you know, and fall back otherwise.
Find the bugInteractive · disabled in the PDF
An older version of the site saved themes like "sepia". Returning visitors now get an unstyled page. Which line lets the old value in?
TypeScript
Never store secrets. Every script running on the site can read localStorage. Passwords, sign-in tokens and personal data don’t belong there. Sign-in usually lives in cookies the server sets and scripts can’t read.
Nano Stores99 / 155
Module 11 · Remembering state between visits
Saved values and server-drawn HTML
On the server there is no localStorage, so islands are drawn with the initial value: a light page, an empty cart. In the browser, the store already holds the saved value when the island starts. If they differ, so does the first render: the hydration mismatch of module 9.
QuizInteractive · disabled in the PDF
A returning visitor has 2 items saved. How should CartBadge read $cart to avoid a mismatch?
A returning visitor has 2 items saved. How should CartBadge read $cart to avoid a mismatch?
Settings that must apply before anything loads. The theme is visible in the very first frame. Waiting for an island means a flash of the wrong theme. Sites often add a tiny inline script in the page’s <head> that reads the saved theme and sets an attribute before the page paints; the store then takes over for changes.
Nano Stores100 / 155
Module 11 · Remembering state between visits
Recap: persistence
persistentAtom(key, initial) is an atom saved to localStorage, read when it is created.
Non-text values need encode / decode, or persistentBoolean / persistentJSON.
persistentMap(prefix, initial) saves one key per field.
Mounted stores follow other tabs; { listen: false } opts out; a waking store re-reads storage.
Validate what you decode, and never store secrets.
Servers have no storage: hydrate with the initial value, then show the saved one.
FlashcardsInteractive · disabled in the PDF
persistentAtom(key, initial, opts?)
An atom kept in localStorage under key.
encode / decode
Turn a value into saved text, and saved text back into a value.
persistentJSON
Saves JSON; falls back to the initial value on broken text.
persistentMap(prefix, initial)
A map with each field saved under prefix + key.
storage event
Fired in other tabs of a site when its localStorage changes.
{ listen: false }
Don’t follow changes from other tabs.
Module 12: Async data: loading, errors and races
Nano Stores101 / 155
Module 12 · Async data: loading, errors and races
Some work finishes later: promises and await
Asking a server for the product list takes time: maybe 50 ms, maybe 5 seconds, maybe never. JavaScript doesn’t stop and wait. fetch(url) returns a promise at once: a placeholder for an answer that will arrive later, or a failure. Inside an async function, await pauses that function (and only it) until the promise settles.
While data loads, a page has three honest things to show: a spinner, the data, or an error. One field, state, says which; the other fields exist only when they make sense. TypeScript then refuses items unless you checked state === "ready" first. This shape is called a discriminated union.
Lines 1: The request starts when the first widget listens to $products. Pages without a product list never fetch.
Lines 2-3:.then runs when the answer arrives: the store becomes ready, and every listening widget redraws.
Lines 4-6:.catch runs if it failed: no network, a server error, bad JSON. The store says so, instead of loading forever.
Two widgets on the page both list products? One request. The first listener starts it, the second joins a store that is already loading. That is the store doing for the page what each widget would otherwise do for itself.
Nano Stores104 / 155
Module 12 · Async data: loading, errors and races
An old answer can arrive after a new one
Search makes this concrete. The visitor types m, then mu. Two requests go out. Nothing says they come back in order: if the answer for m is slower, it arrives last and overwrites the results for mu. This is a race condition: the outcome depends on which finishes first.
Find the bugInteractive · disabled in the PDF
Results sometimes don’t match the search box. Which line needs a check before it?
An AbortController can cancel a fetch it was given the signal of. Returned as the cleanup, it cancels the request if every widget goes away before the answer arrives. A cancelled fetch fails, so the catch checks whether it was cancelled on purpose, and leaves the store alone.
1exportconst $checkout =map<{ busy:boolean; error?:string}>({2busy:false3})45exportasyncfunctionplaceOrder(){6if($checkout.get().busy)return7 $checkout.set({busy:true})8try{9awaitsendOrder($cart.get())10clearCart()11 $checkout.set({busy:false})12}catch{13 $checkout.set({busy:false,error:'The order failed.'})14}15}
The busy check stops a double click from placing two orders.
The cart is read when the order is sent, and cleared only once it succeeded.
Every path ends with busy: false, so the button is never stuck.
Nano Stores107 / 155
Module 12 · Async data: loading, errors and races
Try it: load a list, or say why not
The tests hand loadBooks a fake fetchBooks made with vi.fn(): one that resolves with books, or one that rejects, so both paths can be checked without a network.
Code challengeInteractive · disabled in the PDF
Finish loadBooks(fetchBooks): set { state: 'loading' }, await fetchBooks(), then set { state: 'ready', items }, or { state: 'failed', error: 'Could not load books' } if it throws.
1import{ allTasks, task }from'nanostores'23onMount($products,()=>{4task(async()=>{5const items =awaitloadProducts()6 $products.set({state:'ready', items })7})8})910// elsewhere, in a test or on a server:11awaitallTasks()// resolves once no task is running
task(fn) runs an async function and counts it as running until it settles. allTasks() returns a promise that resolves once nothing is running, including tasks started while waiting. Tests use it to wait for exactly the store’s work (module 13), instead of guessing with a timer.
QuizInteractive · disabled in the PDF
Why await allTasks() rather than waiting 100 ms with a timer?
Why await allTasks() rather than waiting 100 ms with a timer?
Nano Stores109 / 155
Module 12 · Async data: loading, errors and races
For serious fetching, use a store made for it
Package
What it adds
@nanostores/query
Fetcher stores with caching, refetching and deduplication of requests
@nanostores/async
Async computed stores, the replacement for promises in computed
useLoadingStore (React 2)
Suspends a component while a store has isLoading: true, for <Suspense>
Tip. Hand-written loading in onMount is right for one or two simple requests. Once you need caching, retries, invalidation after a change, or the same data keyed by id, a dedicated library has solved those problems already. Read its docs for the version you install.
Nano Stores110 / 155
Module 12 · Async data: loading, errors and races
Recap: async data
A promise stands for a later answer; await waits for it inside an async function.
Model loading, ready and failed as a union, so impossible states can’t exist.
Start loading in onMount: one request for the page, only when used.
Answers can arrive out of order: drop stale ones, cancel with AbortController.
Async actions guard against double runs and always end in a settled state.
task and allTasks let tests and servers wait for a store’s work.
FlashcardsInteractive · disabled in the PDF
Promise
A placeholder for an answer that arrives later, or a failure.
Discriminated union
A type of alternatives told apart by one field, like state.
Race condition
A result that depends on which of several async tasks finishes first.
AbortController
Cancels a fetch given its signal, with abort().
task(fn) / allTasks()
Count async store work / wait until none is running.
Module 13: Testing stores
Nano Stores111 / 155
Module 13 · Testing stores
A test is code that checks your code
A test runs a piece of your code and checks the result, automatically, every time you ask. Stores are easy to test: they are plain functions and values, no browser or framework needed. This module uses Vitest, a test runner: a tool that finds test files, runs them, and reports what passed.
Terminal
1npminstall--save-devvitest2npxvitest# runs every *.test.ts file, re-runs on save
--save-dev marks it as a tool for development, not part of the site.
Test
Code that runs part of your program and checks the result is what you expect.
Test runner
A tool that finds and runs tests and reports which passed: Vitest here.
Assertion
One check inside a test: expect(value).toBe(3).
Nano Stores112 / 155
Module 13 · Testing stores
Test an action by calling it and reading the store
WalkthroughInteractive · disabled in the PDF
stores/cart.test.ts
1import{ expect, it }from'vitest'2import{ $cart, $itemCount, addToCart }from'./cart'34const mug ={id:'mug',name:'Blue mug',price:1200}56it('adds the same product twice as quantity 2',()=>{7addToCart(mug)8addToCart(mug)9expect($cart.get()).toHaveLength(1)10expect($cart.get()[0].quantity).toBe(2)11expect($itemCount.get()).toBe(2)12})
Lines 1:it declares one test; expect makes assertions.
Lines 2: Import the real store module: the same code the page runs.
Lines 4: A small product to test with.
Lines 6: The test’s name reads as a sentence about behaviour.
Lines 7-11: Act through the action, then read the stores. get() on the computed $itemCount works out its value on demand.
Tip. Test through actions and stores, like widgets use them. A test that only calls $cart.set checks nothing of yours.
Nano Stores113 / 155
Module 13 · Testing stores
Stores outlive each test: reset them
A test file imports the store module once, so every test in the file shares the same $cart. Whatever one test adds is still there for the next. Reset the stores you depend on before each test.
Find the bugInteractive · disabled in the PDF
Each test passes alone, but run together the second fails. Which line should run before each test instead?
TypeScript
Nano Stores114 / 155
Module 13 · Testing stores
Try it: reset before each test
Here your code includes a hook for the tests. Each test below passes on its own; together the second fails, because the first left a mug in the cart.
Code challengeInteractive · disabled in the PDF
Add a beforeEach under the store that calls clearCart(), so every test starts with an empty cart.
TypeScript
1import{ atom }from'nanostores'23exportconst $cart =atom<string[]>([])4exportconstaddToCart=(id:string)=>5 $cart.set([...$cart.get(), id])6exportconstclearCart=()=> $cart.set([])78// TODO: reset the cart before each test
Nano Stores115 / 155
Module 13 · Testing stores
cleanStores removes listeners and runs cleanups
TypeScript
1import{ cleanStores, keepMount }from'nanostores'2import{ afterEach, beforeEach }from'vitest'34beforeEach(()=>{5 $online.set(true)// values are yours to reset6})78afterEach(()=>{9cleanStores($online, $itemCount)10})
cleanStores(...stores) removes every listener, runs onMount cleanups at once, forgets computed values, and forgets running tasks.
It doesn’t put an atom back to its starting value: reset values yourself.
keepMount($store) adds a listener that never leaves, so a store stays mounted for the whole test.
Both are for development and tests: cleanStores throws in a production build.
QuizInteractive · disabled in the PDF
A test sets $theme to "dark". The next test only calls cleanStores($theme) first. What is $theme.get() there?
A test sets $theme to "dark". The next test only calls cleanStores($theme) first. What is $theme.get() there?
Nano Stores116 / 155
Module 13 · Testing stores
Test cleanup under fake timers
Cleanup runs STORE_UNMOUNT_DELAY (1000 ms) after the last listener leaves. Waiting a real second per test would be slow. Fake timers replace the clock, so a test can move time forward instantly. Import the constant instead of writing 1000: the test then says why.
Fill the blanksInteractive · disabled in the PDF
Check that $online stops watching the window a second after its last listener leaves.
TypeScript
1import{ STORE_UNMOUNT_DELAY }from'nanostores'2import{ expect, it, vi }from'vitest'34it('stops listening when unused',()=>{5 vi.()6const remove = vi.spyOn(window,'removeEventListener')7const stop = $online.listen(()=>{})8stop()9 vi.advanceTimersByTime()10expect(remove).('online', expect.any(Function))11 vi.useRealTimers()12})
Nano Stores117 / 155
Module 13 · Testing stores
Wait for async store work with allTasks()
TypeScript
1import{ allTasks }from'nanostores'2import{ expect, it, vi }from'vitest'34it('loads products on first use',async()=>{5 vi.stubGlobal('fetch', vi.fn(async()=>6newResponse(JSON.stringify([mug]))7))8const stop = $products.listen(()=>{})9awaitallTasks()10expect($products.get()).toEqual({state:'ready',items:[mug]})11stop()12})
vi.stubGlobal swaps fetch for a fake that answers at once, so no real server is needed.
Put in orderInteractive · disabled in the PDF
Put the steps of testing a store that loads on mount in order.
1.Assert on the store’s value
2.Await allTasks()
3.Remove the listener and clean the store
4.Replace the network with a fake answer
5.Add a listener, so the store mounts and starts loading
Listed under setupFiles in the Vitest config, so it runs before each test file.
useTestStorageEngine swaps localStorage for an in-memory object. setTestStorageKey(key, value) writes to it as if another tab had, notifying mounted stores. getTestStorage() shows what was saved. cleanTestStorage() empties it.
QuizInteractive · disabled in the PDF
Why call useTestStorageEngine() in a setup file, rather than inside a test?
Why call useTestStorageEngine() in a setup file, rather than inside a test?
Nano Stores119 / 155
Module 13 · Testing stores
Test a widget when the wiring is what matters
TSX
1import{ render, screen }from'@testing-library/react'2import{ expect, it }from'vitest'34it('shows the item count',()=>{5 $cart.set([{...mug,quantity:3}])6render(<CartBadge/>)7expect(screen.getByText('3')).toBeTruthy()8})
Testing Library draws a component into a fake page and finds things in it as a person would, by their text. Set the store, render, read. Test stores alone when their logic is rich (rules, lifecycles, loading); test the widget when you want to know it reads the right store and shows it.
Nano Stores120 / 155
Module 13 · Testing stores
Recap: testing
Stores are plain code: call actions, read stores, assert.
Stores are shared by all tests in a file: reset them in beforeEach.
cleanStores removes listeners and runs cleanups, but doesn’t reset atom values.
Use fake timers and STORE_UNMOUNT_DELAY to test cleanup.
Fake the network, then await allTasks() for work wrapped in task().
Set up the persistent test engine in a setup file, before stores are created.
FlashcardsInteractive · disabled in the PDF
beforeEach vs beforeAll
Before every test / once before all of them.
cleanStores(...stores)
Remove listeners, run cleanups, forget computed values and tasks.
keepMount($store)
Keep a store mounted with a listener that never leaves.
Fake timers
A replaced clock tests can move forward instantly.
useTestStorageEngine()
Make persistent stores save to memory instead of localStorage.
Try itInteractive · disabled in the PDF
Module 14: Performance, batching and memory
Nano Stores121 / 155
Module 14 · Performance, batching and memory
A change costs its listeners’ work
Setting a store is cheap: compare, store, loop over listeners. What costs is what the listeners do: re-rendering components, recomputing derived stores, touching the page. So performance work with stores is mostly about who gets notified, and how often.
Fewer notifications: group writes that belong together (batch, batched).
Fewer listeners per change: small stores, keys, computed stores that return small values.
Cheaper listeners: work out values once in a computed store, not in every component.
Measure first: React DevTools’ Profiler shows which components re-render and why.
Tip. Most pages never need any of this. Reach for it when a profile shows a store change re-rendering much more than it should.
Inside batch, listeners wait. When the outermost batch returns, each listener runs at most once, with the final values. A computed store built on $discount and $couponCode never sees the new discount with the old code. Batches can nest; only the outermost one delivers. batch arrived in 1.4: check that your version exports it.
Predict the outputInteractive · disabled in the PDF
How many times is compute logged after the first listener is added?
batched is a computed store that, when its sources change, waits for a timer of zero milliseconds (the next tick) before working out its value and notifying. A “reset filters” button that sets three stores in a row triggers one search, not three. Reading it with get() still gives a fresh value at once.
Tool
Listeners hear
Use it for
computed
Each source change, at once
The default: always in step
batch(() => …)
Once, when the function returns
Writes you make together, in one place
batched
Once, a tick after the last change
Derived values fed by changes from many places
Nano Stores126 / 155
Module 14 · Performance, batching and memory
Computed stores that return new objects always notify
A line’s quantity goes from 2 to 3. Which readers are notified: of $summary (left), $count, $empty?
A line’s quantity goes from 2 to 3. Which readers are notified: of $summary (left), $count, $empty?
Nano Stores127 / 155
Module 14 · Performance, batching and memory
Listeners run in a queue, in order
When a listener sets another store, Nano Stores doesn’t run that store’s listeners in the middle. It adds them to the end of a queue, after the listeners already waiting. Every listener of the first change runs before any listener of the second.
Predict the outputInteractive · disabled in the PDF
Replacing an array copies it: changing one of 5,000 products builds a new 5,000-item array, and every reader of the list is notified. For big collections, keep entries in a map keyed by id, and give each row its own narrow subscription.
TSX
1exportconst $stock =map<Record<string,number>>({})23functionStockCell({ id }:{ id:string}){4const stock =useStore($stock,{keys:[id]})5return<td>{stock[id]??0}</td>6}78// one product's stock changes: one cell re-renders9$stock.setKey('mug',4)
A map still copies its top-level object on setKey, but readers listening to other keys aren’t told.
Nano Stores129 / 155
Module 14 · Performance, batching and memory
Memory leaks: listeners that outlive their widget
Find the bugInteractive · disabled in the PDF
Each time the mini-cart popup opens and closes, memory grows and old popups’ code still runs. Which line leaks?
TypeScript
Stores made on demand need freeing too. A store created per product id and kept in a cache lives forever unless something removes it. Module 16 shows a factory that removes a store from its cache when its last listener leaves, using onMount’s cleanup.
Nano Stores130 / 155
Module 14 · Performance, batching and memory
Recap: performance
A change costs what its listeners do: aim for fewer, narrower notifications.
batch(fn) delivers synchronous writes once, with final values; it doesn’t cover await.
batched notifies a tick after the last change; get() is still fresh.
Computed stores returning numbers or booleans filter out non-changes; new objects never do.
Listeners run in a queue: those of the first change before those of a change they make.
Key big collections by id; always stop hand-written subscriptions.
FlashcardsInteractive · disabled in the PDF
batch(fn)
Hold notifications until fn returns; each listener runs at most once.
batched(stores, fn)
A computed store that notifies once, a tick after changes.
Why does batch(async …) fail?
The batch closes at the first await; later writes are outside it.
Cheap derived values
Return primitives from computed stores, so equal results don’t notify.
Listener queue
A store set inside a listener notifies after the listeners already waiting.
Most common leak
A hand-written subscription never stopped, holding a removed widget.
Module 15: Stores and the server
Nano Stores131 / 155
Module 15 · Stores and the server
In the browser one visitor; on a server, everyone
A store is a module-level value: one per running program. In a browser tab the program serves one visitor, so one cart per program is exactly right. A server is one program answering many visitors. A store there is shared by every request it handles, one after another, and with async code, interleaved.
Two requests, one server, one module-level store.
Server-side rendering (SSR)
Drawing a page’s HTML on a server for each request, rather than in the browser.
Request
One visitor’s ask for one page; a server handles many, often at the same time.
Nano Stores132 / 155
Module 15 · Stores and the server
Never put one visitor’s data in a module-level store on the server
Find the bugInteractive · disabled in the PDF
Visitors sometimes see someone else’s name in the header. Which line causes it?
Astro
Note. Static builds have the same trap in a milder form: one build process draws every page, so a store set while drawing one page still holds that value for the next.
Nano Stores133 / 155
Module 15 · Stores and the server
Hand per-request data to islands as props
Server page
---const user =awaitgetUser(Astro.request)---<AccountMenuname={user.name}client:load/>
The island
exportfunctionAccountMenu({ name }:{ name:string}){return<button>{name}</button>}
Props are the inputs a component receives. The server draws the island with them, writes them into the page, and the browser starts the island with the same values. Each request draws its own props, so nothing is shared. Two islands that both need the user each get it as a prop.
QuizInteractive · disabled in the PDF
Which of these can an Astro page pass to an island as a prop?
Which of these can an Astro page pass to an island as a prop?
Nano Stores134 / 155
Module 15 · Stores and the server
Store side effects can run during server rendering
Drawing an island on the server calls useStore, which reads the store with get(). On a sleeping store, get() mounts it (module 10), so onMount runs on the server. A fetch there goes out from the server, maybe without the visitor’s cookies; window there doesn’t exist and throws.
TypeScript
1onMount($online,()=>{2if(typeof window ==='undefined')return// on a server3constupdate=()=> $online.set(navigator.onLine)4update()5 window.addEventListener('online', update)6 window.addEventListener('offline', update)7return()=>{8 window.removeEventListener('online', update)9 window.removeEventListener('offline', update)10}11})
typeof window is safe even where window doesn’t exist; reading window itself would throw.
QuizInteractive · disabled in the PDF
Without the guard, what happens when the page with the online badge is built on a server?
Without the guard, what happens when the page with the online badge is built on a server?
Nano Stores135 / 155
Module 15 · Stores and the server
Top-level code in store modules runs on the server too
Code at the top of a module runs the moment the module is first imported: on the server while drawing, in the browser before islands start. Anything that needs the browser either waits for onMount or checks first. Even guarded, the server’s value ("name") and the browser’s (?sort=price) can differ: hydrate with ssr: () => "name". Not ssr: "initial": in the browser, the store was created with the URL’s value.
Nano Stores136 / 155
Module 15 · Stores and the server
Seeding a store from server data, in the browser
TSX
1exportfunctionProductList({ initial }:{ initial:Product[]}){2useEffect(()=>{3seedProducts(initial)// sets $products if still loading4},[])5const live =useStore($products)6const items = live.state ==='ready'? live.items : initial7// …8}
The server fetches the catalogue and passes it as a prop: no loading spinner in the HTML.
Until the store is ready, the island shows the prop, so the server HTML and hydration match.
useEffect runs only in the browser, after hydration: there the action fills the shared store, so other islands get the data too.
On the server, nothing is written to the shared store.
Put in orderInteractive · disabled in the PDF
Put the life of a server-drawn page with a store in order.
3.The server draws the island’s HTML from its props
4.The browser receives the HTML and shows it
5.The island re-renders with the live store values
Nano Stores137 / 155
Module 15 · Stores and the server
Recap: the server
On a server, a module-level store is shared by every request and every page.
Never set per-visitor data in a store on the server: pass it to islands as props.
Props must be plain data: no stores or functions.
useStore reads with get(), which can mount a store and run onMount on the server: guard browser-only work.
Top-level module code runs on import, on the server too: guard location, window, storage.
Seed shared stores in the browser, after hydration.
FlashcardsInteractive · disabled in the PDF
SSR
Drawing a page’s HTML on a server, per request.
Why not $user.set() in an Astro page?
The store is shared by every request the server handles.
Props
A component’s inputs; for islands, plain data written into the page.
typeof window === "undefined"
A safe test for “not in a browser”.
Can onMount run on a server?
Yes: useStore calls get(), which mounts a sleeping store.
Module 16: Designing a store module’s API
Nano Stores138 / 155
Module 16 · Designing a store module’s API
A store module is an API: stores to read, actions to call
An API is the set of things one piece of code offers to others. For a store module, that is what it exports. A good one is small and hard to misuse: read-only stores to show, actions to change them, and types. Everything else stays inside.
stores/cart.ts
1const $lines =atom<CartLine[]>([])23/** The cart’s lines, in the order they were added. */4exportconst $cart =readonlyType($lines)5/** Number of items, counting quantities. */6exportconst $itemCount =computed($lines, countItems)7/** Total in cents. */8exportconst $totalCents =computed($lines, sumCents)910exportfunctionaddToCart(product:Product):void{/* … */}11exportfunctionsetQuantity(id:string, n:number):void{/* … */}12exportfunctionremoveFromCart(id:string):void{/* … */}13exportfunctionclearCart():void{/* … */}1415exporttype{ CartLine }
/** … */ comments document each export; editors show them on hover.
Units in names ($totalCents) prevent the classic mistake of showing cents as euros.
Readers can’t write: there is no way to change the cart except the four actions.
Nano Stores139 / 155
Module 16 · Designing a store module’s API
Keep the writable store and helpers private
QuizInteractive · disabled in the PDF
Which of these should stores/cart.ts export? Pick all that apply.
Which of these should stores/cart.ts export? Pick all that apply.
Change the inside freely. Because readers only see $cart, the actions and the types, you can switch the inside from an array to a map keyed by id (module 14) without touching a single widget: only the computed store and the actions change.
Nano Stores140 / 155
Module 16 · Designing a store module’s API
Stores you need one of per id
Some state is per thing: the reviews of each product, the stock of each item. A store per id, made on demand, lets each widget subscribe to just its product. A factory is a function that makes such stores; a cache remembers the ones already made, so two widgets for the same product share one.
Lines 1: A Map (JavaScript’s built-in key-value collection, not a Nano Stores map) holds the stores made so far.
Lines 3-5: Asked for a product’s store, the factory returns the cached one if it exists.
Lines 6-13: Otherwise it makes one, which starts watching that product’s reviews on mount, and stops on cleanup.
Lines 11: The cleanup also removes the store from the cache, so stores nobody uses don’t pile up in memory.
Lines 14-17: Remember it, and return it.
Watch out. Calling the factory while rendering, as in useStore(reviewsOf(id)), is safe because the cache returns the same store on every render. Without a cache, each render would make a new store and subscribe to a different one each time.
Nano Stores141 / 155
Module 16 · Designing a store module’s API
Try it: one store per product, shared
Code challengeInteractive · disabled in the PDF
Make 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.
mapCreator(init) returns a function that makes, or returns from its cache, a map store per id. The store starts as { id }. init runs when the store mounts, and what it returns runs on cleanup, after which the store leaves the cache. It is the factory of the last slide, built in, for map stores.
Fill the blanksInteractive · disabled in the PDF
Read one product’s stock in a component.
TSX
1functionStock({ id }:{ id:string}){2const{ stock }=()3return<span>{stock ??'…'} left</span>4}
Nano Stores143 / 155
Module 16 · Designing a store module’s API
Layer modules so dependencies point one way
Widgetsread stores, call actions
Feature storescheckout.ts: uses cart and prefs
Core storescart.ts, prefs.ts, online.ts
Plain helpers and API callsno stores
Each layer imports only from layers below it.
When cart.ts imports from checkout.ts and checkout.ts imports from cart.ts, you have an import cycle: one of them runs before the other has finished, and reads values that don’t exist yet. Decide which module is lower, and let only the higher one import the lower.
QuizInteractive · disabled in the PDF
clearCart must also reset the checkout’s error message, which lives in checkout.ts, which imports the cart. Where should that logic go?
clearCart must also reset the checkout’s error message, which lives in checkout.ts, which imports the cart. Where should that logic go?
An async action can do both: return a promise, so its caller can wait (and tests can too), and record the result in a store, so every widget agrees. The store is the truth; the promise is a convenience.
Nano Stores145 / 155
Module 16 · Designing a store module’s API
Reviewing a store design
Is each store as small as its readers need, and named with $ and units?
Is every derived value a computed store, pure and synchronous?
Do all writes go through actions, with the writable store kept private?
Does each onMount undo everything it starts, and skip browser-only work on servers?
Are saved and URL values validated? Is nothing secret stored?
Do islands hydrate with what the server drew? Do tests reset what they touch?
Find the bugInteractive · disabled in the PDF
Review this module. Which line breaks its design the most?
TypeScript
Nano Stores146 / 155
Module 16 · Designing a store module’s API
Recap: store design
Export read-only stores, actions and types; keep writable stores and helpers private.
Document exports, and put units in names.
Per-id state: a factory with a cache, freed in onMount’s cleanup, or mapCreator.
Layer modules so imports point one way; avoid cycles.
Report outcomes in stores so every widget agrees; return promises for waiting.
Review with a checklist: size, derivation, actions, cleanup, validation, hydration, tests.
FlashcardsInteractive · disabled in the PDF
API
What a piece of code offers others: for a store module, its exports.
Factory with cache
A function making a store per id, returning the same store for the same id.
mapCreator(init)
A built-in factory of map stores per id, freed after their last listener leaves.
Import cycle
Two modules importing each other; one reads the other before it is ready.
Why outcomes in stores?
Every widget, not only the caller, can show them.
Module 17: Mastery challenge
Nano Stores147 / 155
Module 17 · Mastery challenge
Predict the notifications: maps and computed
Predict the outputInteractive · disabled in the PDF
$online has an onMount that logs start and a cleanup that logs stop. What is logged?
Nano Stores149 / 155
Module 17 · Mastery challenge
Debug: a row that doesn’t update
The checkout panel draws each line with a memo row: a React component that re-renders only when its props change. After adding a product already in the cart, the badge’s count goes up, but that product’s row still shows the old quantity.
Find the bugInteractive · disabled in the PDF
Which line is the root cause?
TypeScript
Nano Stores150 / 155
Module 17 · Mastery challenge
Debug: a flash and a hydration error
Returning visitors with a saved cart see a React hydration error in the console, and the header badge flickers. The badge reads useStore($cart), and $cart is a persistentJSON store.
QuizInteractive · disabled in the PDF
What is the right fix?
What is the right fix?
Nano Stores151 / 155
Module 17 · Mastery challenge
Design: a remembered, validated setting
Fill the blanksInteractive · disabled in the PDF
Remember the currency across visits and tabs, accepting only known values.
TypeScript
1const CURRENCIES =['usd','eur','gbp']asconst2typeCurrency=(typeof CURRENCIES)[number]3constisCurrency=(text:string): text isCurrency=>4(CURRENCIES asreadonlystring[]).includes(text)56const $saved =<Currency>('currency','usd',{7encode:(value)=> value,8decode:(text)=>(? text :'usd')9})1011exportconst $currency =($saved)1213exportfunctionsetCurrency(next:Currency){14 $saved.(next)15}
Nano Stores152 / 155
Module 17 · Mastery challenge
Trace a lazy store’s life on a page
Put in orderInteractive · disabled in the PDF
The product list loads on first use and is shown by two islands. Put its life in order.
1.Both islands are removed; a second later, the cleanup runs
2.The second island subscribes and joins the request already running
3.The page loads; the store module runs and creates $products in its loading state
4.The answer arrives: one set, both islands re-render
5.The first island subscribes: onMount starts the request
QuizInteractive · disabled in the PDF
Which of these are safe in an Astro page’s server code? Pick all that apply.
Which of these are safe in an Astro page’s server code? Pick all that apply.
Which review comments are right? Pick all that apply.
Which review comments are right? Pick all that apply.
Nano Stores154 / 155
Module 17 · Mastery challenge
Capstone: shared state for a small shop
Build a one-page shop with three islands, a header badge, a product list and a checkout panel, plus a plain script for the theme. Use everything: it is the best test of whether the ideas have landed.
Build itInteractive · disabled in the PDF
Nano Stores155 / 155
Module 17 · Mastery challenge
Recap: the whole course
State shared by separate widgets belongs in a store: a value you can read, set and subscribe to.
Atoms for values, maps for fields, computed stores for everything derived; never mutate.
Actions beside the stores hold the rules; export stores read-only.
Stores are lazy: onMount starts work on the first listener and cleans up after the last.
Bindings connect stores to React, Vue, Svelte and plain scripts; islands share modules.
Persist with validation; load async data without races; test with allTasks and cleanStores.
Batch related writes, keep subscriptions narrow, stop what you start, and keep per-visitor data off the server’s stores.
FlashcardsInteractive · disabled in the PDF
Listener of a batched change gets…
The final value, and the old value from before the batch.
Computed result unchanged?
No notification, whatever changed in the sources.
When does onMount’s cleanup run?
1 s after the last listener leaves, unless a new one arrives first.
Persisted value + server HTML
Hydrate with the server’s value, ssr: () => [], then show the saved one. Not "initial": a persistent store is created from the saved value.
Per-visitor data on the server
Props, never a module-level store.
Three review questions
Is it derived? Do writes go through actions? Does onMount clean up?
Answer key
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.
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.
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.
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.
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.
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.
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.
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.
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
1functioncreateStore(initial){2let value = initial3const listeners =[]4return{5get(){6return value7},8set(newValue){9if(Object.is(value, newValue))return10 value = newValue11for(const listener of listeners)listener(value)12},13subscribe(listener){14 listeners.push(listener)15listener(value)16return()=>{17const index = listeners.indexOf(listener)18if(index !==-1) listeners.splice(index,1)19}20}21}22}
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.
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.
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.
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).
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.
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.
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.
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.
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:
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.
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.
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.
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.
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
1import{ map }from'nanostores'23exportconst $quantities =map<Record<string,number>>({})45exportfunctionaddOne(id:string){6const now = $quantities.get()[id]??07 $quantities.setKey(id, now +1)8}910exportfunctionremoveOne(id:string){11const now = $quantities.get()[id]??012 $quantities.setKey(id, now >1? now -1:undefined)13}
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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]).
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
1import{ atom }from'nanostores'23typeTodo={ id:number; text:string; done:boolean}45exportconst $todos =atom<Todo[]>([])6let lastId =07constnextId=()=>++lastId89exportfunctionaddTodo(text:string){10const trimmed = text.trim()11if(trimmed ==='')return12const todo ={id:nextId(),text: trimmed,done:false}13 $todos.set([...$todos.get(), todo])14}1516exportfunctiontoggleTodo(id:number){17const todos = $todos.get().map((todo)=>18 todo.id === id ?{...todo,done:!todo.done }: todo19)20 $todos.set(todos)21}
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
1import{ atom }from'nanostores'23typeBooks=4|{ state:'idle'|'loading'}5|{ state:'ready'; items:string[]}6|{ state:'failed'; error:string}78exportconst $books =atom<Books>({state:'idle'})910exportasyncfunctionloadBooks(11fetchBooks:()=>Promise<string[]>12){13 $books.set({state:'loading'})14try{15const items =awaitfetchBooks()16 $books.set({state:'ready', items })17}catch{18 $books.set({state:'failed',error:'Could not load books'})19}20}
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.
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.
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.
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
1import{ atom }from'nanostores'23exportconst $cart =atom<string[]>([])4exportconstaddToCart=(id:string)=>5 $cart.set([...$cart.get(), id])6exportconstclearCart=()=> $cart.set([])78beforeEach(()=>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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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 intotarget: 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.
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.
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.
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.
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.
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.