- Notifications
You must be signed in to change notification settings - Fork111
A tiny (286 bytes) state manager for React/RN/Preact/Vue/Svelte with many atomic tree-shakable stores
License
nanostores/nanostores
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
A tiny state manager forReact,React Native,Preact,Vue,Svelte,Solid,Lit,Angular, and vanilla JS.It uses many atomic stores and direct manipulation.
- Small. Between 265 and 803 bytes (minified and brotlied).Zero dependencies. It uses Size Limit to control size.
- Fast. With small atomic and derived stores, you do not need to callthe selector function for all components on every store change.
- Tree Shakable. A chunk contains only stores used by componentsin the chunk.
- Designed to move logic from components to stores.
- GoodTypeScript support.
// store/users.tsimport{atom}from'nanostores'exportconst$users=atom<User[]>([])exportfunctionaddUser(user:User){$users.set([...$users.get(),user]);}
// store/admins.tsimport{computed}from'nanostores'import{$users}from'./users.js'exportconst$admins=computed($users,users=>users.filter(i=>i.isAdmin))
// components/admins.tsximport{useStore}from'@nanostores/react'import{$admins}from'../stores/admins.js'exportconstAdmins=()=>{constadmins=useStore($admins)return(<ul>{admins.map(user=><UserItemuser={user}/>)}</ul>)}
Made atEvil Martians, product consulting fordeveloper tools.
npm install nanostores
- Persistent store to save datato
localStorage
and synchronize changes between browser tabs. - Router store to parse URLand implements SPA navigation.
- I18n library based on storesto make application translatable.
- Query store that helps you with smartremote data fetching.
- Logux Client: stores with WebSocketsync and CRDT conflict resolution.
- Logger of lifecycles, changesin the browser console.
- Vue Devtools plugin that detectsstores and attaches them to devtools inspectors and timeline.
Atom store can be used to store strings, numbers, arrays.
You can use it for objects too if you want to prohibit key changesand allow only replacing the whole object (like we do inrouter).
To create it callatom(initial)
and pass initial value as a first argument.
import{atom}from'nanostores'exportconst$counter=atom(0)
In TypeScript, you can optionally pass value type as type parameter.
exporttypeLoadingStateValue='empty'|'loading'|'loaded'exportconst$loadingState=atom<LoadingStateValue>('empty')
Then you can useStoreValue<Store>
helper to get store’s value typein TypeScript:
importtype{StoreValue}from'nanostores'typeValue=StoreValue<typeof$loadingState>//=> LoadingStateValue
store.get()
will return store’s current value.store.set(nextValue)
will change value.
$counter.set($counter.get()+1)
store.subscribe(cb)
andstore.listen(cb)
can be used to subscribefor the changes in vanilla JS. ForReact/Vuewe have extra special helpersuseStore
to re-render the component onany store changes.
Listener callbacks will receive the updated value as a first argumentand the previous value as a second argument.
constunbindListener=$counter.subscribe((value,oldValue)=>{console.log(`counter value changed from${oldValue} to${value}`)})
store.subscribe(cb)
in contrast withstore.listen(cb)
also call listenersimmediately during the subscription.Note that the initial call forstore.subscribe(cb)
will not have anyprevious value andoldValue
will beundefined
.
Map store can be used to store objects with one level of depth and change keysin this object.
To create map store callmap(initial)
function with initial object.
import{map}from'nanostores'exportconst$profile=map({name:'anonymous'})
In TypeScript you can pass type parameter with store’s type:
exportinterfaceProfileValue{name:string,email?:string}exportconst$profile=map<ProfileValue>({name:'anonymous'})
store.set(object)
orstore.setKey(key, value)
methods will change the store.
$profile.setKey('name','Kazimir Malevich')
Settingundefined
will remove optional key:
$profile.setKey('email',undefined)
Store’s listeners will receive third argument with changed key.
$profile.listen((profile,oldProfile,changed)=>{console.log(`${changed} new value${profile[changed]}`)})
You can also listen for specific keys of the store being changed, usinglistenKeys
andsubscribeKeys
.
listenKeys($profile,['name'],(value,oldValue,changed)=>{console.log(`$profile.Name new value${value.name}`)})
subscribeKeys(store, keys, cb)
in contrast withlistenKeys(store, keys, cb)
also call listeners immediately during the subscription.Please note that when using subscribe for store changes, the initial evaluationof the callback has undefined old value and changed key.
Deep maps work the same asmap
, but it supports arbitrary nesting of objectsand arrays that preserve the fine-grained reactivity.
import{deepMap,listenKeys}from'nanostores'exportconst$profile=deepMap({hobbies:[{name:'woodworking',friends:[{id:123,name:'Ron Swanson'}]}],skills:[['Carpentry','Sanding'],['Varnishing']]})listenKeys($profile,['hobbies[0].friends[0].name','skills[0][0]'])// Won't fire subscription$profile.setKey('hobbies[0].name','Scrapbooking')$profile.setKey('skills[0][1]','Staining')// But those will fire subscription$profile.setKey('hobbies[0].friends[0].name','Leslie Knope')$profile.setKey('skills[0][0]','Whittling')
Note thatsetKey
creates copies as necessary so that no part of the originalobject is mutated (but it does not do a full deep copy -- some sub-objects maystill be shared between the old value and the new one).
A unique feature of Nano Stores is that every state has two modes:
- Mount: when one or more listeners is mounted to the store.
- Disabled: when store has no listeners.
Nano Stores was created to move logic from components to the store.Stores can listen for URL changes or establish network connections.Mount/disabled modes allow you to create lazy stores, which will use resourcesonly if store is really used in the UI.
onMount
sets callback for mount and disabled states.
import{onMount}from'nanostores'onMount($profile,()=>{// Mount modereturn()=>{// Disabled mode}})
For performance reasons, store will move to disabled mode with 1 second delayafter last listener unsubscribing.
CallkeepMount()
to test store’s lazy initializer in tests andcleanStores
to unmount them after test.
import{cleanStores,keepMount}from'nanostores'import{$profile}from'./profile.js'afterEach(()=>{cleanStores($profile)})it('is anonymous from the beginning',()=>{keepMount($profile)// Checks})
Computed store is based on other store’s value.
import{computed}from'nanostores'import{$users}from'./users.js'exportconst$admins=computed($users,users=>{// This callback will be called on every `users` changesreturnusers.filter(user=>user.isAdmin)})
An async function can be evaluated by usingtask()
.
import{computed,task}from'nanostores'import{$userId}from'./users.js'exportconst$user=computed($userId,userId=>task(async()=>{constresponse=awaitfetch(`https://my-api/users/${userId}`)returnresponse.json()}))
By default,computed
stores updateeach time any of their dependenciesgets updated. If you are fine with waiting until the end of a tick, you canusebatched
. The only difference withcomputed
is that it will wait untilthe end of a tick to update itself.
import{batched}from'nanostores'const$sortBy=atom('id')const$categoryId=atom('')exportconst$link=batched([$sortBy,$categoryId],(sortBy,categoryId)=>{return`/api/entities?sortBy=${sortBy}&categoryId=${categoryId}`})// `batched` will update only once even you changed two storesexportfunctionresetFilters(){$sortBy.set('date')$categoryIdFilter.set('1')}
Bothcomputed
andbatched
can be calculated from multiple stores:
import{$lastVisit}from'./lastVisit.js'import{$posts}from'./posts.js'exportconst$newPosts=computed([$lastVisit,$posts],(lastVisit,posts)=>{returnposts.filter(post=>post.publishedAt>lastVisit)})
startTask()
andtask()
can be used to mark all async operationsduring store initialization.
import{task}from'nanostores'onMount($post,()=>{task(async()=>{$post.set(awaitloadPost())})})
You can wait for all ongoing tasks end in tests or SSR withawait allTasks()
.
import{allTasks}from'nanostores'$post.listen(()=>{})// Move store to active mode to start data loadingawaitallTasks()consthtml=ReactDOMServer.renderToString(<App/>)
Each store has a few events, which you listen:
onMount(store, cb)
: first listener was subscribed with debounce.We recommend to always useonMount
instead ofonStart + onStop
,because it has a short delay to prevent flickering behavior.onStart(store, cb)
: first listener was subscribed. Low-level method.It is better to useonMount
for simple lazy stores.onStop(store, cb)
: last listener was unsubscribed. Low-level method.It is better to useonMount
for simple lazy stores.onSet(store, cb)
: before applying any changes to the store.onNotify(store, cb)
: before notifying store’s listeners about changes.
onSet
andonNotify
events hasabort()
function to prevent changesor notification.
import{onSet}from'nanostores'onSet($store,({ newValue, abort})=>{if(!validate(newValue)){abort()}})
Event listeners can communicate withpayload.shared
object.
Use@nanostores/react
or@nanostores/preact
packageanduseStore()
hook to get store’s value and re-render componenton store’s changes.
import{useStore}from'@nanostores/react'// or '@nanostores/preact'import{$profile}from'../stores/profile.js'exportconstHeader=({ postId})=>{constprofile=useStore($profile)return<header>Hi,{profile.name}</header>}
Use@nanostores/vue
anduseStore()
composable functionto get store’s value and re-render component on store’s changes.
<script setup>import {useStore }from'@nanostores/vue'import { $profile }from'../stores/profile.js'constprops=defineProps(['postId'])constprofile=useStore($profile)</script><template> <header>Hi, {{ profile.name }}</header></template>
Every store implementsSvelte's store contract. Put$
before store variableto get store’s value and subscribe for store’s changes.
<script>import {profile }from'../stores/profile.js'</script><header>Hi, {$profile.name}</header>
In other frameworks, Nano Stores promote code style to use$
prefixesfor store’s names. But in Svelte it has a special meaning, so we recommendto not follow this code style here.
Use@nanostores/solid
anduseStore()
composable functionto get store’s value and re-render component on store’s changes.
import{useStore}from'@nanostores/solid'import{$profile}from'../stores/profile.js'exportfunctionHeader({ postId}){constprofile=useStore($profile)return<header>Hi,{profile().name}</header>}
Use@nanostores/lit
andStoreController
reactive controllerto get store’s value and re-render component on store’s changes.
import{StoreController}from'@nanostores/lit'import{$profile}from'../stores/profile.js'@customElement('my-header')classMyElementextendsLitElement{ @property()privateprofileController=newStoreController(this,$profile)render(){returnhtml\`<header>Hi,${profileController.value.name}</header>`}}
Use@nanostores/angular
andNanostoresService
withuseStore()
method to get store’s value and subscribe for store’s changes.
// NgModule:import{NANOSTORES,NanostoresService}from'@nanostores/angular';@NgModule({providers:[{provide:NANOSTORES,useClass:NanostoresService}]})
// Component:import{Component}from'@angular/core'import{NanostoresService}from'@nanostores/angular'import{Observable,switchMap}from'rxjs'import{profile}from'../stores/profile'import{IUser,User}from'../stores/user'@Component({selector:"app-root",template:'<p *ngIf="(currentUser$ | async) as user">{{ user.name }}</p>'})exportclassAppComponent{currentUser$:Observable<IUser>=this.nanostores.useStore(profile).pipe(switchMap(userId=>this.nanostores.useStore(User(userId))))constructor(privatenanostores:NanostoresService){}}
Store#subscribe()
calls callback immediately and subscribes to store changes.It passes store’s value to callback.
import{$profile}from'../stores/profile.js'$profile.subscribe(profile=>{console.log(`Hi,${profile.name}`)})
Store#listen(cb)
in contrast calls only on next store change. It could beuseful for a multiple stores listeners.
functionrender(){console.log(`${$post.get().title} for${$profile.get().name}`)}$profile.listen(render)$post.listen(render)render()
See alsolistenKeys(store, keys, cb)
to listen for specific keys changesin the map.
Nano Stores support SSR. Use standard strategies.
if(isServer){$settings.set(initialSettings)$router.open(renderingPageURL)}
You can wait for async operations (for instance, data loadingvia isomorphicfetch()
) before rendering the page:
import{allTasks}from'nanostores'$post.listen(()=>{})// Move store to active mode to start data loadingawaitallTasks()consthtml=ReactDOMServer.renderToString(<App/>)
Adding an empty listener bykeepMount(store)
keeps the storein active mode during the test.cleanStores(store1, store2, …)
cleansstores used in the test.
import{cleanStores,keepMount}from'nanostores'import{$profile}from'./profile.js'afterEach(()=>{cleanStores($profile)})it('is anonymous from the beginning',()=>{keepMount($profile)expect($profile.get()).toEqual({name:'anonymous'})})
You can useallTasks()
to wait all async operations in stores.
import{allTasks}from'nanostores'it('saves user',async()=>{saveUser()awaitallTasks()expect(analyticsEvents.get()).toEqual(['user:save'])})
Stores are not only to keep values. You can use them to track time, to load datafrom server.
import{atom,onMount}from'nanostores'exportconst$currentTime=atom<number>(Date.now())onMount($currentTime,()=>{$currentTime.set(Date.now())constupdating=setInterval(()=>{$currentTime.set(Date.now())},1000)return()=>{clearInterval(updating)}})
Use derived stores to create chains of reactive computations.
import{computed}from'nanostores'import{$currentTime}from'./currentTime.js'constappStarted=Date.now()exportconst$userInApp=computed($currentTime,currentTime=>{returncurrentTime-appStarted})
We recommend moving all logic, which is not highly related to UI, to the stores.Let your stores track URL routing, validation, sending data to a server.
With application logic in the stores, it is much easier to write and run tests.It is also easy to change your UI framework. For instance, add React Nativeversion of the application.
Use a separated listener to react on new store’s value, not an action functionwhere you change this store.
function increase() { $counter.set($counter.get() + 1)- printCounter(store.get()) }+ $counter.listen(counter => {+ printCounter(counter)+ })
An action function is not the only way for store to a get new value.For instance, persistent store could get the new value from another browser tab.
With this separation your UI will be ready to any source of store’s changes.
get()
returns current value and it is a good solution for tests.
But it is better to useuseStore()
,$store
, orStore#subscribe()
in UIto subscribe to store changes and always render the actual data.
- const { userId } = $profile.get()+ const { userId } = useStore($profile)
Nano Stores use ESM-only package. You need to use ES modulesin your application to import Nano Stores.
In Next.js ≥11.1 you can alternatively use theesmExternals
config option.
For old Next.js you need to usenext-transpile-modules
to fixlack of ESM support in Next.js.
About
A tiny (286 bytes) state manager for React/RN/Preact/Vue/Svelte with many atomic tree-shakable stores