Skip to content
Merged
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,30 @@ export default function App() {
}
```

### 🎭 Isolated State

Need to run multiple independent instances of your application or isolate features? Use `StateScopeProvider`.

```tsx
import { AutoRootCtx, StateScopeProvider } from 'react-state-custom'

Comment on lines +124 to +125
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README example imports StateScopeProvider from react-state-custom, but it is not currently re-exported from the package entrypoint (src/index.ts). Consumers following this snippet will get a missing export error; please re-export StateScopeProvider from the public index (or adjust the docs if it’s intentionally internal).

Suggested change
import { AutoRootCtx, StateScopeProvider } from 'react-state-custom'
import { AutoRootCtx } from 'react-state-custom'
// Example of a custom scope provider component wrapping an isolated part of your app.
function StateScopeProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot re-export it

function App() {
return (
<>
<AutoRootCtx /> {/* Global Scope */}
<MainApp />

<StateScopeProvider>
{/* Isolated Scope - Stores here are independent of Global Scope */}
<IsolatedFeature />
</StateScopeProvider>
</>
)
}
```

Stores used inside `StateScopeProvider` will be completely isolated from the parent or global scope, even if they share the same store definition.

---

## 🆚 Comparison
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export {
} from "./state-utils/ctx"

export { createRootCtx } from "./state-utils/createRootCtx"
export { AutoRootCtx, createAutoCtx, createStore } from "./state-utils/createAutoCtx"
export { AutoRootCtx, createAutoCtx, createStore, StateScopeProvider } from "./state-utils/createAutoCtx"
export { useArrayChangeId } from "./state-utils/useArrayChangeId"
export { paramsToId, type ParamsToIdRecord, type ParamsToIdInput } from "./state-utils/paramsToId"

Expand Down
16 changes: 14 additions & 2 deletions src/state-utils/createAutoCtx.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState, Fragment, useCallback, useMemo, Activity } from "react"
import { useDataContext, useDataSourceMultiple, useDataSubscribe, type Context } from "./ctx"
import { useEffect, useState, Fragment, useCallback, useMemo, useId } from "react"
import { useDataContext, useDataSourceMultiple, useDataSubscribe, StateScopeContext, type Context } from "./ctx"
import { createRootCtx } from "./createRootCtx"
import { paramsToId, type ParamsToIdRecord } from "./paramsToId"
import { useQuickSubscribe } from "./useQuickSubscribe"
Expand Down Expand Up @@ -183,3 +183,15 @@ export const createStore = <U extends ParamsToIdRecord, V extends Record<string,
) => {
return createAutoCtx(createRootCtx(name, useFn), timeToClean, AttatchedComponent)
}

export const StateScopeProvider: React.FC<{
children: React.ReactNode
Wrapper?: React.FC<any>
debugging?: boolean
}> = ({ children, Wrapper, debugging }) => {
const scopeId = useId()
return <StateScopeContext.Provider value={scopeId}>
<AutoRootCtx Wrapper={Wrapper} debugging={debugging} />
{children}
</StateScopeContext.Provider>
}
32 changes: 19 additions & 13 deletions src/state-utils/createRootCtx.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"
import { useDataContext, useDataSourceMultiple, type Context } from "./ctx"
import { useContext, useEffect, useMemo } from "react"
import { useDataContext, useDataSourceMultiple, StateScopeContext, type Context } from "./ctx"
import { paramsToId, type ParamsToIdRecord } from "./paramsToId"
import { DependencyTracker } from "./utils"
// import { debugObjTime } from "./debugObjTime"
Expand Down Expand Up @@ -48,9 +48,11 @@ export const createRootCtx = <U extends ParamsToIdRecord, V extends Record<strin

const useRootState = (e: U) => {
const ctxName = getCtxName(e)
const scopeId = useContext(StateScopeContext)
const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName
const ctx = useDataContext<V>(ctxName)

DependencyTracker.enter(ctxName);
DependencyTracker.enter(scopedCtxName);
let state;
try {
state = useFn(e, { ...ctx.data })
Expand All @@ -66,13 +68,13 @@ export const createRootCtx = <U extends ParamsToIdRecord, V extends Record<strin
)

useEffect(() => {
if (ctxMountedCheck.has(ctxName)) {
const err = new Error("RootContext " + ctxName + " are mounted more than once")
if (ctxMountedCheck.has(scopedCtxName)) {
const err = new Error("RootContext " + scopedCtxName + " are mounted more than once")
err.stack = stack;
throw err
}
ctxMountedCheck.add(ctxName)
return () => { ctxMountedCheck.delete(ctxName) };
ctxMountedCheck.add(scopedCtxName)
return () => { ctxMountedCheck.delete(scopedCtxName) };
})

return state;
Expand Down Expand Up @@ -100,16 +102,18 @@ export const createRootCtx = <U extends ParamsToIdRecord, V extends Record<strin
*/
useCtxStateStrict: (e: U): Context<V> => {
const ctxName = getCtxName(e)
const scopeId = useContext(StateScopeContext)
const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName

const stack = useMemo(() => new Error().stack, [])

useEffect(() => {
if (!ctxMountedCheck.has(ctxName)) {
const err = new Error("RootContext [" + ctxName + "] is not mounted")
if (!ctxMountedCheck.has(scopedCtxName)) {
const err = new Error("RootContext [" + scopedCtxName + "] is not mounted")
err.stack = stack;
throw err
}
}, [ctxName])
}, [scopedCtxName])

return useDataContext<V>(ctxName)
},
Expand All @@ -119,17 +123,19 @@ export const createRootCtx = <U extends ParamsToIdRecord, V extends Record<strin
*/
useCtxState: (e: U): Context<V> => {
const ctxName = getCtxName(e)
const scopeId = useContext(StateScopeContext)
const scopedCtxName = scopeId ? `${scopeId}/${ctxName}` : ctxName

const stack = useMemo(() => new Error().stack, [])

useEffect(() => {
if (!ctxMountedCheck.has(ctxName)) {
const err = new Error("RootContext [" + ctxName + "] is not mounted")
if (!ctxMountedCheck.has(scopedCtxName)) {
const err = new Error("RootContext [" + scopedCtxName + "] is not mounted")
err.stack = stack;
let timeout = setTimeout(() => console.error(err), 1000)
return () => clearTimeout(timeout)
}
}, [ctxMountedCheck.has(ctxName)])
}, [ctxMountedCheck.has(scopedCtxName)])

return useDataContext<V>(ctxName)
Comment on lines 136 to 140
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useCtxState’s mount-check effect depends on ctxMountedCheck.has(scopedCtxName), which won’t re-run/cleanup when the Root becomes mounted because the Set mutation doesn’t trigger a re-render. This can cause a delayed console.error even when the Root mounts successfully. Use a stable dependency like scopedCtxName (and re-check inside the effect) so the timeout can be cleared when appropriate.

Copilot uses AI. Check for mistakes.
}
Expand Down
12 changes: 8 additions & 4 deletions src/state-utils/ctx.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { debounce, memoize, DependencyTracker } from "./utils";
import { useEffect, useMemo, useState } from "react"
import { createContext, useContext, useEffect, useMemo, useState } from "react"
import { useArrayChangeId } from "./useArrayChangeId"



export const StateScopeContext = createContext<string | null>(null)

const CHANGE_EVENT = "@--change-event"

class DataEvent<D> extends Event {
Expand Down Expand Up @@ -119,16 +121,18 @@ export type getContext<D> = (e: string) => Context<D>
* @returns The Context instance.
*/
export const useDataContext = <D>(name: string = "noname") => {
DependencyTracker.addDependency(name);
const ctx = useMemo(() => getContext(name), [name])
const scopeId = useContext(StateScopeContext)
const namespacedName = scopeId ? `${scopeId}/${name}` : name
DependencyTracker.addDependency(namespacedName);
const ctx = useMemo(() => getContext(namespacedName), [namespacedName])
useEffect(() => {
ctx.useCounter += 1;
return () => {
ctx.useCounter -= 1;
if (ctx.useCounter <= 0) {
setTimeout(() => {
if (ctx.useCounter <= 0) {
getContext.cache.delete(JSON.stringify([name]))
getContext.cache.delete(JSON.stringify([namespacedName]))
}
}, 100)
}
Expand Down
71 changes: 71 additions & 0 deletions tests/createAutoCtx.nested.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import React from 'react'
import { createAutoCtx, AutoRootCtx, StateScopeProvider } from '../src/state-utils/createAutoCtx'
import { createRootCtx } from '../src/state-utils/createRootCtx'
import { useDataSubscribe } from '../src/state-utils/ctx'

describe('AutoRootCtx Isolation', () => {
it('should isolate state between nested scopes', async () => {
// Create a simple counter store
const useCounter = () => {
const [count, setCount] = React.useState(0)
return {
count,
increment: () => setCount(c => c + 1)
}
}

const rootCtx = createRootCtx('isolation-test', useCounter)
const autoCtx = createAutoCtx(rootCtx)

function Consumer({ label }: { label: string }) {
const ctx = autoCtx.useCtxState({})
const count = useDataSubscribe(ctx, 'count')
const increment = useDataSubscribe(ctx, 'increment')

return (
<div data-testid={`consumer-${label}`}>
<span data-testid={`count-${label}`}>{count}</span>
<button onClick={increment} data-testid={`btn-${label}`}>
Increment
</button>
</div>
)
}

render(
<>
<AutoRootCtx />
<Consumer label="outer" />

<StateScopeProvider>
<Consumer label="inner" />
</StateScopeProvider>
</>
)

// Initial state: both should be 0
await waitFor(() => {
expect(screen.getByTestId('count-outer').textContent).toBe('0')
expect(screen.getByTestId('count-inner').textContent).toBe('0')
})

// Increment outer
fireEvent.click(screen.getByTestId('btn-outer'))

await waitFor(() => {
expect(screen.getByTestId('count-outer').textContent).toBe('1')
// Inner should remain 0 if isolated
expect(screen.getByTestId('count-inner').textContent).toBe('0')
})

// Increment inner
fireEvent.click(screen.getByTestId('btn-inner'))

await waitFor(() => {
expect(screen.getByTestId('count-outer').textContent).toBe('1')
expect(screen.getByTestId('count-inner').textContent).toBe('1')
})
})
})