Scoped stores allow you to create isolated store instances for different parts of your component tree, perfect for reusable components or multi-tenant applications.
Why Scoped Stores?
Global stores are great for application-wide state, but sometimes you need:
Isolated state for reusable components
Multiple instances of the same component with different data
Server-side rendering with per-request state
Multi-tenant applications with separate data per tenant
Basic Usage
Create a scoped store using createScopedStore:
import { createScopedStore } from 'stan-js'
export const { StoreProvider , useStore } = createScopedStore ({
user: '' ,
counter: 0
})
Wrap components with StoreProvider to create isolated instances:
import { StoreProvider , useStore } from './store'
const Counter = () => {
const { counter , setCounter } = useStore ()
return (
< div >
< span >{ counter } </ span >
< button onClick = {() => setCounter ( prev => prev + 1 )} >+ </ button >
</ div >
)
}
const App = () => (
< div >
< StoreProvider initialValue = {{ user : 'Alice' }} >
< Counter /> { /* Independent counter */ }
</ StoreProvider >
< StoreProvider initialValue = {{ user : 'Bob' }} >
< Counter /> { /* Independent counter */ }
</ StoreProvider >
</ div >
)
Each StoreProvider creates a completely isolated store instance.
Initial Values
Override specific state values when creating a provider:
const { StoreProvider , useStore } = createScopedStore ({
firstName: 'John' ,
lastName: 'Smith'
})
const App = () => (
< StoreProvider initialValue = {{ lastName : 'Doe' }} >
< User />
</ StoreProvider >
)
const User = () => {
const { firstName , lastName } = useStore ()
// firstName: 'John' (default), lastName: 'Doe' (overridden)
return < div >{ firstName } { lastName } </ div >
}
Only the values you specify in initialValue are overridden. Other values use the defaults from createScopedStore.
Nested Providers
Providers can be nested for hierarchical state:
const { StoreProvider , useStore } = createScopedStore ({
userName: 'Guest'
})
const User = ({ id } : { id : string }) => {
const { userName } = useStore ()
return < p data - testid ={ id }>{ userName } </ p >
}
const App = () => (
<>
< User id = "1" /> { /* Guest */ }
< StoreProvider initialValue = {{ userName : 'Alice' }} >
< User id = "2" /> { /* Alice */ }
< StoreProvider initialValue = {{ userName : 'Bob' }} >
< User id = "3" /> { /* Bob */ }
</ StoreProvider >
</ StoreProvider >
</>
)
From src/tests/scoped.test.tsx:9:
expect ( screen . getByTestId ( '1' ). innerText ). toBe ( 'Tonny Jest' )
expect ( screen . getByTestId ( '2' ). innerText ). toBe ( 'Johnny Test' )
expect ( screen . getByTestId ( '3' ). innerText ). toBe ( 'Test Johnny' )
Accessing the Store Instance
Use useScopedStore() to access the entire store API:
const { StoreProvider , useScopedStore } = createScopedStore ({
counter: 0
})
const Counter = () => {
const store = useScopedStore ()
// Access all store methods
const state = store . getState ()
store . actions . setCounter ( 5 )
store . reset ()
return < div >{state. counter } </ div >
}
Higher-Order Component Pattern
Use withStore to wrap components with a provider:
const { withStore , useStore } = createScopedStore ({
firstName: 'John' ,
lastName: 'Smith'
})
const User = () => {
const { firstName , lastName } = useStore ()
return < div >{ firstName } { lastName } </ div >
}
// Wrap with provider and initial values
const UserWithStore = withStore ( User , { lastName: 'Doe' })
// Use anywhere without manual provider wrapping
const App = () => < UserWithStore />
Dynamic Initial Values
Initial values can change, and the store will update:
const { StoreProvider , useStore } = createScopedStore ({
message: ''
})
const Display = () => {
const { message } = useStore ()
return < p >{ message } </ p >
}
const App = () => {
const [ text , setText ] = useState ( 'Initial' )
return (
<>
< input onChange = { e => setText ( e . target . value )} />
< StoreProvider initialValue = {{ message : text }} >
< Display />
</ StoreProvider >
</>
)
}
When initialValue changes, the scoped store updates automatically (from src/scoped.tsx:33).
Changing initialValue triggers updates. Use this intentionally for dynamic initialization, not for frequent updates.
Scoped Effects
Use useStoreEffect with scoped stores:
const { StoreProvider , useStoreEffect } = createScopedStore ({
counter: 0
})
const Logger = () => {
useStoreEffect (({ counter }) => {
console . log ( 'Counter in this scope:' , counter )
})
return null
}
const App = () => (
<>
< StoreProvider initialValue = {{ counter : 1 }} >
< Logger /> { /* Logs changes from scope 1 */ }
</ StoreProvider >
< StoreProvider initialValue = {{ counter : 2 }} >
< Logger /> { /* Logs changes from scope 2 */ }
</ StoreProvider >
</>
)
SSR with Scoped Stores
Scoped stores are perfect for server-side rendering:
// store.ts
import { createScopedStore } from 'stan-js'
export const { StoreProvider , useStore } = createScopedStore ({
user: '' ,
counter: 0
})
// page.tsx (Next.js)
import { StoreProvider } from './store'
export default function Page ({ user } : { user : string }) {
return (
< StoreProvider initialValue = {{ user }} >
< Counter />
</ StoreProvider >
)
}
Each request gets its own isolated store instance.
Computed Values in Scoped Stores
Computed values work seamlessly:
const { StoreProvider , useStore } = createScopedStore ({
firstName: 'John' ,
lastName: 'Smith' ,
get fullName () {
return ` ${ this . firstName } ${ this . lastName } `
}
})
const User = () => {
const { fullName } = useStore ()
return < div >{ fullName } </ div >
}
const App = () => (
< StoreProvider initialValue = {{ lastName : 'Doe' }} >
< User /> { /* John Doe */ }
</ StoreProvider >
)
Use Cases
Reusable Components Create self-contained components with their own state management.
Modal Dialogs Each modal instance gets its own state, preventing conflicts.
Multi-step Forms Isolate form state per instance while sharing the form component.
SSR Applications Per-request state isolation in Next.js, Remix, or other SSR frameworks.
Real-World Example
A reusable wizard component:
// wizard-store.ts
import { createScopedStore } from 'stan-js'
export const { StoreProvider , useStore , withStore } = createScopedStore ({
currentStep: 0 ,
data: {} as Record < string , unknown >
})
export const useWizard = () => {
const { currentStep , data , setCurrentStep , setData } = useStore ()
const nextStep = () => setCurrentStep ( prev => prev + 1 )
const prevStep = () => setCurrentStep ( prev => Math . max ( 0 , prev - 1 ))
const updateData = ( key : string , value : unknown ) => {
setData ( prev => ({ ... prev , [key]: value }))
}
return { currentStep , data , nextStep , prevStep , updateData }
}
// Wizard.tsx
import { StoreProvider } from './wizard-store'
import { useWizard } from './wizard-store'
const WizardStep = ({ children } : { children : ReactNode }) => {
const { currentStep , nextStep , prevStep } = useWizard ()
return (
< div >
{ children }
< button onClick = { prevStep } disabled = { currentStep === 0 } > Back </ button >
< button onClick = { nextStep } > Next </ button >
</ div >
)
}
const Wizard = ({ children } : { children : ReactNode }) => (
< StoreProvider >
{ children }
</ StoreProvider >
)
// Usage: Multiple independent wizards
const App = () => (
<>
< Wizard >
< WizardStep > Step 1 </ WizardStep >
</ Wizard >
< Wizard >
< WizardStep > Another wizard , independent state </ WizardStep >
</ Wizard >
</>
)
Best Practices
Use global stores for app-wide state , scoped stores for component-level state
Avoid deeply nested providers – it can make state flow hard to trace
Initialize with meaningful defaults so components work without providers
Document the scope – make it clear which components expect which provider