Skip to content

Repository files navigation

React Store

A lightweight, proxy-based global state management library for React.

Features

  • Lightweight: Minimal footprint with only one dependency beyond React (fast-equals)
  • Proxy-based: JavaScript Proxy enables nested property access with path tracking
  • Dynamic Scoping: Components automatically subscribe only to the specific array indices or object properties they access, so unrelated updates never re-render them
  • Derived stores: Compute values from one or more stores; subscribers only re-render when the derived value actually changes
  • Async stores: First-class loading / error / success handling for data fetching, including async derived stores that re-run when their inputs change
  • Persistence: Back any store with localStorage, sessionStorage, or IndexedDB, with automatic serialization and cross-tab synchronization for both localStorage and IndexedDB
  • Server-rendering ready: Works with Next.js, Remix and any other SSR setup, with hydration-safe snapshots for persisted stores — see Server-Side Rendering
  • No provider, no boilerplate: Stores are plain module-level values usable from any component or from outside React entirely

Live Demo

The examples/demo directory contains a small window manager (draggable / resizable windows, a taskbar, and virtual "spaces") built entirely on this library. It is the clearest demonstration of why derived stores matter — see Why Derived Stores? below. Run it with:

npm install
npm run demo

Installation

npm install @longsien/react-store

Quick Start

import{store,useStore}from'@longsien/react-store'// Create a store with initial valueconstcounterStore=store(0)functionCounter(){// Get current value and setter functionconst[count,setCount]=useStore(counterStore)return(<div><p>Count: {count}</p>{/* Direct value update */}<buttononClick={()=>setCount(count+1)}>+</button>{/* Function-based update */}<buttononClick={()=>setCount(prev=>prev-1)}>-</button></div>)}

As Global Store

You can treat a store as a global store and use it in any component without prop passing and unnecessary re-renders.

import{store,useStore,useStoreValue}from'@longsien/react-store'// Global store accessible from any componentconstpersonStore=store({name: 'Hanni',origin: 'Australia'})// Component that updates store valuesfunctionUpdater(){// Subscribe to specific nested propertiesconst[name,setName]=useStore(personStore.name)const[origin,setOrigin]=useStore(personStore.origin)return(<div><inputtype='text'value={name}onChange={e=>setName(e.target.value)}/><inputtype='text'value={origin}onChange={e=>setOrigin(e.target.value)}/></div>)}// Read-only component for namefunctionDisplayName(){// Only re-renders when name changesconstname=useStoreValue(personStore.name)return<div>Name: {name}</div>}// Read-only component for originfunctionDisplayOrigin(){// Only re-renders when origin changesconstorigin=useStoreValue(personStore.origin)return<div>Origin: {origin}</div>}

API Reference

Store Creation

store(initialValue)

Creates a basic in-memory store that persists for the lifetime of the application session.

// Basic in-memory storeconstuserStore=store({name: 'Winter',origin: 'South Korea'})

store(initialValue).local(key)

Creates a store backed by localStorage with automatic persistence. Data is automatically serialized to JSON when saving and deserialized when loading. If the key already exists in storage, the stored value is used and the initial value is ignored.

localStorage-backed stores also synchronize across tabs: when another tab writes to the same key, the store updates and re-renders subscribers. Object references for unchanged nested paths are preserved during the sync so only the components reading changed paths re-render.

// Store with localStorage persistence (and cross-tab sync)constsettingsStore=store({theme: 'dark'}).local('settings')

store(initialValue).session(key)

Creates a store backed by sessionStorage with automatic persistence. Data is automatically serialized to JSON when saving and deserialized when loading.

// Store with sessionStorage persistenceconsttempStore=store({items: []}).session('temp-data')

store(initialValue).index(storeName, dbName?)

Creates a store backed by IndexedDB. The initial value is used synchronously until the asynchronous read completes, after which the persisted value (if any) is loaded in. dbName defaults to 'react-store'.

IndexedDB stores also synchronize across tabs, via BroadcastChannel where available. Every store sharing a dbName shares a single connection, so any number of them can live in one database. If another tab needs to upgrade that database, this one releases its connection so the upgrade isn't blocked, and reopens on the next read or write.

Unlike .local() and .session(), values are stored via structured clone rather than JSON, so Date, Map, Set and binary values (Blob, File, ArrayBuffer, typed arrays) round-trip intact.

// Store with IndexedDB persistenceconstdocsStore=store({drafts: []}).index('documents')// Custom database nameconstusersStore=store({name: 'Winter'}).index('users','my-app')

Hooks

useStore(store)

Returns [value, setState] tuple for reading and updating state. Use exactly the same as React's built-in useState hook.

// Returns [value, setter] tuple like useStateconst[user,setUser]=useStore(userStore)const[userName,setUserName]=useStore(userStore.name)const[userOrigin,setUserOrigin]=useStore(userStore.origin)

useStoreValue(store)

Returns only the current value (read-only).

// Read-only access, no setter returnedconstuser=useStoreValue(userStore)constuserName=useStoreValue(userStore.name)constuserOrigin=useStoreValue(userStore.origin)

useStoreSetter(store)

Returns only the setter function, avoiding unnecessary re-renders when the value changes.

// Only get setter function, avoids re-rendersconstsetUser=useStoreSetter(userStore)constsetUserName=useStoreSetter(userStore.name)constsetUserOrigin=useStoreSetter(userStore.origin)

Non-Hook Functions

store.get()

Get current value outside React components. Useful for utility functions, event handlers, or any code that runs outside the React render cycle.

// Get values outside React componentsconstcurrentUser=userStore.get()constuserName=userStore.name.get()constuserOrigin=userStore.origin.get()

store.set(value)

Update value outside React components. Triggers all subscribed components to re-render if their specific data has changed. Accepts the same value types as the hook-based setters.

// Update values outside React componentsuserStore.set({name: 'Karina',origin: 'South Korea'})userStore.name.set('Ningning')userStore.origin.set('China')

store.destroy()

Cleans up resources held by a store. Any debounced write still pending is flushed first, so a set() immediately followed by destroy() is never lost. It then stops persisting, removes the cross-tab storage listener for localStorage stores, releases the IndexedDB connection, and unregisters derived stores from their dependencies.

constsettingsStore=store({theme: 'dark'}).local('app-settings')settingsStore.set({theme: 'light'})// The pending write is flushed, then the store stops persisting:settingsStore.destroy()

Call it when a persisted store is no longer needed, so it releases its storage listener or database connection promptly. Module-level stores that live for the lifetime of the app don't need it.

Derived stores don't require destroy() to avoid leaking. A source store holds its dependents weakly, so a derived store that the application has dropped — one created inside a component that has since unmounted, say — becomes eligible for garbage collection and stops recomputing on its own. Calling destroy() on it simply makes that immediate and deterministic.

store.hydrate({ storage, key, debounce? })

Attaches localStorage or sessionStorage persistence to an existing in-memory store. Before hydrate() runs, the store does not touch browser storage and set() only updates memory.

If the key already exists, its stored value replaces the store value. If the key does not exist, it is created from the store's current value. Subsequent changes are persisted automatically. hydrate() returns the same store instance, so existing subscribers and derived stores stay connected.

constsettings=store({theme: 'dark'})settings.set({theme: 'sepia'})// memory only// Existing "settings" data wins; otherwise the current sepia value is stored.settings.hydrate({storage: 'local',key: 'settings'})// Leave the old key as-is, then bind to settings.v2 using the same rules.settings.hydrate({storage: 'local',key: 'settings.v2'})

Use storage: 'session' for sessionStorage. Passing debounce configures writes for the new binding. Calling hydrate() again cancels any pending write to the previous binding, leaving that key at its last persisted value. Calling it after destroy() binds the store again.

hydrate() can also retarget a store that was created with .local() or .session():

constsettings=store({theme: 'dark'}).local('settings',{debounce: 5000,})settings.set({theme: 'sepia'})// pending write to "settings"settings.hydrate({storage: 'local',key: 'settings.v2'})

After the retarget:

  • The pending write to settings is cancelled, so that key keeps its last persisted value.
  • If settings.v2 already exists, its value replaces the store's current value.
  • If settings.v2 is missing, it is created from the current { theme: 'sepia' } value.
  • All future changes persist to settings.v2 only.

Derived Stores

Derived stores automatically compute values based on other stores and update when their dependencies change. A derived store re-runs its getter when a dependency changes, but only notifies its own subscribers when the computed value actually changes (by deep equality). This makes them the primary tool for minimizing re-renders.

Why Derived Stores?

Dynamic scoping already lets a component subscribe to a single nested path. Derived stores go further: they let a component subscribe to a computed projection of state — a list of keys, a boolean, a sum — and re-render only when that projection changes, no matter how often the underlying store churns.

The window manager demo shows two cases where this is the difference between a smooth UI and one that re-renders everything on every mouse move.

1. Subscribing to the shape of a store, not its contents.

All open windows live in one store, keyed by id. Each window's position, size, and zIndex update many times per second while dragging or resizing. The component that renders the list of windows only cares about which windows exist — not their contents. A derived store projects the store down to its keys, returning the same array reference when the set of keys hasn't changed:

exportconstwindowsStore=store({}).local('wm-windows')letcachedIds=[]exportconstwindowIdsStore=windowsStore.derive(windows=>{constkeys=Object.keys(windows)// Return the cached reference when the id set is unchanged so subscribers// don't re-render on every position/size update inside a window.if(keys.length===cachedIds.length&&keys.every((id,i)=>id===cachedIds[i]))returncachedIdscachedIds=keysreturnkeys})constWindowManager=()=>{// Re-renders only when a window is opened or closed — never while dragging.constwindowIds=useStoreValue(windowIdsStore)returnwindowIds.map(id=><Windowkey={id}id={id}/>)}

Without the derived store, WindowManager would subscribe to windowsStore directly and re-render the entire window list on every drag frame.

2. Fanning a shared value out into per-item slices.

There is a single focusedWindowStore holding the id of the focused window. If every window subscribed to it directly, focusing one window would re-render all of them. Instead, each window derives its own boolean. When focus moves from A to B, only A's and B's derived values flip from/to true — every other window's derived value stays false, so it doesn't re-render:

exportconstfocusedWindowStore=store(null)constfocusCache=newMap()exportconstgetWindowFocusStore=id=>{if(!focusCache.has(id)){focusCache.set(id,focusedWindowStore.derive(focusedId=>focusedId===id))}returnfocusCache.get(id)}constWindow=({ id })=>{// Only the two windows whose focus actually changed re-render.constisFocused=useStoreValue(getWindowFocusStore(id))// ...}

The takeaway: reach for a derived store whenever a component depends on a function of state rather than the raw state. Deep-equality gating on the computed result is what keeps re-renders proportional to meaningful changes instead of to write frequency.

Basic Derived Stores

import{store,useStore}from'@longsien/react-store'// Base storeconstcounterStore=store(0)// Derived from counterStoreconstdoubledStore=counterStore.derive(count=>count*2)// Derived from doubledStore (chained derivation)constdoubledAgainStore=doubledStore.derive(count=>count*2)functionCounter(){const[count,setCount]=useStore(counterStore)const[doubled]=useStore(doubledStore)const[doubledAgain]=useStore(doubledAgainStore)return(<div><p>Count: {count}</p><p>Doubled: {doubled}</p><p>Doubled Again: {doubledAgain}</p><buttononClick={()=>setCount(count+1)}>Increment</button></div>)}

Multi-Dependency Derived Stores

import{store,useStore}from'@longsien/react-store'// Multiple independent storesconstnameStore=store('Winter')constoriginStore=store('South Korea')constisActiveStore=store(true)// Derived store combining multiple dependenciesconstuserProfileStore=store(get=>({name: get(nameStore),origin: get(originStore),isActive: get(isActiveStore),// Computed values based on dependenciesdisplayName: `${get(nameStore)} (${get(originStore)})`,status: get(isActiveStore) ? 'Online' : 'Offline',canPerformActions: get(isActiveStore)&&get(originStore)!=='Unknown',}))functionUserProfile(){// Automatically updates when any dependency changesconst[userProfile]=useStore(userProfileStore)return(<div><h3>{userProfile.displayName}</h3><p>Status: {userProfile.status}</p><p>Can perform actions: {userProfile.canPerformActions ? 'Yes' : 'No'}</p></div>)}

Async Stores

Async stores handle asynchronous operations with built-in loading, error, and success states.

Basic Async Store

import{store,useStoreValue,isSuccess}from'@longsien/react-store'// Async store that fetches data on creationconstpokemonStore=store().async(()=>fetch(`https://pokeapi.co/api/v2/pokemon/pikachu`).then(res=>res.json()))functionPokemon(){constpokemon=useStoreValue(pokemonStore)// Check if data is successfully loadedreturn<div>Pokemon: {isSuccess(pokemon)&&pokemon.name}</div>}

Async Derived Store

import{store,useStore,isLoading,isError,isSuccess,getErrorMessage,}from'@longsien/react-store'// Store for Pokemon IDconstpokemonIdStore=store(1)// Async derived store that fetches when ID changesconstpokemonDetailsStore=pokemonIdStore.derive(asyncid=>{constresponse=awaitfetch(`https://pokeapi.co/api/v2/pokemon/${id}`)returnresponse.json()})functionPokemonDetails(){const[pokemonId,setPokemonId]=useStore(pokemonIdStore)const[pokemonDetails]=useStore(pokemonDetailsStore)return(<div><buttononClick={()=>setPokemonId(pokemonId+1)}>Next Pokemon</button>{/* Show loading state */}{isLoading(pokemonDetails)&&<p>Loading Pokemon details...</p>}{/* Show error state */}{isError(pokemonDetails)&&(<p>Error: {getErrorMessage(pokemonDetails)}</p>)}{/* Show success state */}{isSuccess(pokemonDetails)&&(<div><h3>{pokemonDetails.name}</h3><p><imgsrc={pokemonDetails.sprites.front_default}alt={pokemonDetails.name}/></p><p>Height: {pokemonDetails.height}</p><p>Weight: {pokemonDetails.weight}</p></div>)}</div>)}

Async Utility Functions

isLoading(data)

Returns true if the async store is currently loading.

// Check if async operation is in progress{isLoading(pokemonDetails)&&<p>Loading Pokemon...</p>}

isError(data)

Returns true if the async operation failed.

import{isError,getErrorMessage}from'@longsien/react-store'{isError(pokemonDetails)&&<p>Error: {getErrorMessage(pokemonDetails)}</p>}

isSuccess(data)

Returns true if the async operation completed successfully.

// Check if async operation succeeded{isSuccess(pokemonDetails)&&<div>{/* Render success content */}</div>}

getErrorMessage(data)

Returns the error message from a failed async operation.

// Extract error message from failed async operationconsterrorMessage=getErrorMessage(pokemonDetails)

getErrorStatus(data)

Returns the HTTP status code from a failed async operation.

// Extract HTTP status code from failed async operationconststatusCode=getErrorStatus(pokemonDetails)

Nested Property Access

The library uses JavaScript Proxies to enable nested property access. This allows components to subscribe to deeply nested values without re-rendering when unrelated parts of the state change.

import{store,useStore}from'@longsien/react-store'// Nested object structureconstuserStore=store({profile: {name: 'Winter',origin: 'South Korea',settings: {theme: 'dark'},},posts: [],})// Subscribe to specific nested propertiesconst[theme,setTheme]=useStore(userStore.profile.settings.theme)const[origin,setOrigin]=useStore(userStore.profile.origin)const[posts,setPosts]=useStore(userStore.posts)// Updates only affect components using those specific pathssetTheme('light')// Only theme subscribers re-rendersetOrigin('Australia')// Only origin subscribers re-rendersetPosts(prev=>[...prev,newPost])// Only posts subscribers re-render

Dynamic Scoping

Nested property access works with dynamic scoping, allowing dynamic path path subscription based on component props.

Array Index Subscriptions

import{useStore,useStoreValue}from'@longsien/react-store'// Dynamic array index subscriptionconst[comment,setComment]=useStore(commentsStore[index])constauthor=useStoreValue(commentsStore[index].author)

Dynamic Object Property Subscriptions

import{useStore,useStoreSetter}from'@longsien/react-store'// Dynamic object property subscriptionconst[user,setUser]=useStore(usersStore[userId])constsetStatus=useStoreSetter(usersStore[userId].status)

Reading Part of a Store

get() returns the value at whatever path you hand it, and the derived store depends on only that path. A write elsewhere in the same source doesn't recompute it:

constappStore=store({items: [1,2],meta: {hits: 0}})constitemCount=store(get=>get(appStore.items).length)appStore.meta.hits.set(1)// itemCount does not recomputeappStore.items.set([1,2,3])// itemCount recomputes

Paths interact when one contains the other, so correctness never depends on reading at exactly the right depth. Writing appStore as a whole reaches a store that read appStore.items, and writing appStore.items[0] reaches one that read appStore.items. Only genuinely disjoint branches — items against meta — are treated as independent.

This makes narrow reads worth preferring in a large store: get(appStore.items) says exactly what the derived store cares about, and everything else stops waking it up.

Performance Tuning

The defaults suit most applications. These two options matter once a store holds a large amount of data.

Equality

Every write is compared against the current value, and subscribers are notified only if it actually changed. The default comparison is deep, which is what stops a re-render when an immutable update produces a new object holding identical data. That comparison costs O(size) per write, so for a large state replaced wholesale it can dominate:

// 20,000 items, replaced 20 timesstore(bigState)// ~160msstore(bigState,{equals: 'shallow'})// ~0.1msstore(bigState,{equals: 'reference'})// ~0.0ms
OptionComparisonUse when
'deep'(default)Full structuralValues are rebuilt from equal data and you want to suppress those updates
'shallow'Same keys, values by identityImmutable updates — unchanged branches keep their references anyway
'reference'Object.isYou always create a new object when something genuinely changed
functionYoursA version field or id is enough to decide
// Shallow is usually the right upgrade for immutably-updated stateconstboardStore=store(largeBoard,{equals: 'shallow'})// Or compare on whatever actually identifies a changeconstdocStore=store(document,{equals: (a,b)=>a.revision===b.revision})

The option applies to nested writes as well, and .derive() accepts it for the comparison of its computed result:

constsummaryStore=itemsStore.derive(computeSummary,{equals: 'shallow'})

Persistence debounce

Persisted stores debounce their writes. The default of 0 coalesces every change within a tick, but changes spread across ticks — dragging, typing, animating — each trigger a write, and every write re-serializes the entire state:

// 20 updates spread over ~100ms, 20,000 itemsstore(bigState).local('board')// 21 writesstore(bigState).local('board',{debounce: 100})// 2 writes
constboardStore=store(largeBoard).local('board',{debounce: 250,equals: 'shallow',})

Raising debounce never risks losing data: destroy() flushes a pending write rather than dropping it. It does mean a hard tab close within the interval can lose the most recent change, so keep the interval short for data you cannot afford to lose. .session() and .index() take the same option.

On-demand persistence

Create a regular store and call hydrate({ storage, key }) when persistence should begin. Until then it remains entirely in memory. Hydration reads an existing key into the same store or creates a missing key from the store's current value. Calling hydrate() again can switch the same store — including one created with .local() or .session() — to another localStorage or sessionStorage key. Any pending write to the previous binding is cancelled, the previous key is left at its last persisted value, and future updates go only to the new binding. destroy() still flushes the active binding.

Server-Side Rendering

Stores work on the server with no configuration. useStore and useStoreValue supply the server snapshot that React's useSyncExternalStore requires, so components render on the server and hydrate on the client without special-casing.

Persisted stores render their initial value

A server has no localStorage and no IndexedDB, so it cannot know what a returning visitor has stored. A persisted store therefore renders its initial value on the server, and React swaps in the stored value on the first client render after hydration:

// localStorage holds { count: 42 } from a previous visitconstcounter=store({count: 0}).local('counter')constCounter=()=>{const[{ count }]=useStore(counter)return<span>{count}</span>}// Server renders: <span>0</span> ← the initial value// After hydration: <span>42</span> ← the stored value

This is deliberate. Rendering the stored value during hydration would not match the server's HTML, and React would report a hydration mismatch. Outside of rendering, counter.get() returns the stored value immediately as always — only the hydration snapshot is pinned.

If the brief flash of the initial value matters, gate the persisted part of your UI on having mounted:

constCounter=()=>{const[{ count }]=useStore(counter)const[hydrated,setHydrated]=useState(false)useEffect(()=>setHydrated(true),[])return<span>{hydrated ? count : '—'}</span>}

Derived stores follow the same rule: a store derived from a persisted store computes its server value from its source's server value, so the whole chain stays consistent. Async stores render their loading state on the server, since a server render cannot await the promise — use isLoading to render a skeleton.

Missing storage degrades instead of throwing

.local(), .session() and .index() fall back to an ordinary in-memory store when their backend is unavailable, so the same module-level store definition can be imported on the server. In a browser — where a missing backend is a real problem, such as blocked storage or private mode — a warning is logged. Nothing is logged on a server, where the fallback is expected.

Stores are per-process, not per-request

Module-level stores are shared by every request the server handles, exactly as any module-level value is. Never put request-specific or user-specific data in a module-level store on the server — it will leak between requests. Keep server-rendered stores to genuinely global, non-sensitive state, and pass per-request data through props or your framework's own loader/context mechanism.

Requirements

  • React 18.0.0 or higher

License

MIT

Contributing

Issues and pull requests are welcome on GitHub.

Author

Long Sien

About

A lightweight, proxy-based state management library for React with built-in localStorage and sessionStorage support.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages