Global state made as simple as useState, with zero config, built-in async caching, and automatic scoping.
Tiny, ergonomic, conventionโoverโconfiguration state, async function, and real-time subscription sharing for React. Global by default, trivially scoped when you need isolation, and optโin static APIs when you must touch state outside components. As simple as useState
, as flexible as Zustand, without boilerplate like Redux.
- 0 config. Just pick a key:
useSharedState('cart', [])
. - Automatic scoping: nearest
SharedStatesProvider
wins; omit it for global. - Crossโtree sharing via named scopes (two providers with same
scopeName
share data) โ powerful for portals/modals/microโfrontends. - Async functions become cached shared resources via
useSharedFunction
(builtโin loading, error, results, reentrancy guard, manual or forced refresh). - Static APIs (
sharedStatesApi
,sharedFunctionsApi
) let you prime / read / mutate outside React (SSR, event buses, dev tools, tests). - No custom store objects, reducers, actions, selectors, immer, proxies, or serialization hoops.
- Predictable: key + scope โ value. Thatโs it.
npm install react-shared-states
or
pnpm add react-shared-states
import { useSharedState } from 'react-shared-states';
function A(){
const [count, setCount] = useSharedState('counter', 0);
return <button onClick={()=>setCount(c=>c+1)}>A {count}</button>;
}
function B(){
const [count] = useSharedState('counter', 0);
return <span>B sees {count}</span>;
}
function App() {
return (
<>
<A/>
<B/>
</>
)
}
Same key โ same state (global scope by default).
Add a scope:
import { SharedStatesProvider, useSharedState } from 'react-shared-states';
function Scoped(){
const [count, set] = useSharedState('counter', 0); // isolated inside provider
return <button onClick={()=>set(c=>c+1)}>Scoped {count}</button>;
}
function App() {
return (
<>
<A/>
<B/>
<SharedStatesProvider>
<Scoped/>
</SharedStatesProvider>
</>
)
}
Override / jump to a named scope explicitly:
useSharedState('counter', 0, 'modal'); // 3rd arg is scopeName override
Two separate trees with the same SharedStatesProvider scopeName
share their data:
<SharedStatesProvider scopeName="modal">
<ModalContent/>
</SharedStatesProvider>
<Portal target={...}>
<SharedStatesProvider scopeName="modal">
<FloatingToolbar/>
</SharedStatesProvider>
</Portal>
function App() {
return (
<>
<SharedStatesProvider scopeName="modal">
<ModalContent/>
</SharedStatesProvider>
<Portal target={...}>
<SharedStatesProvider scopeName="modal">
<FloatingToolbar/>
</SharedStatesProvider>
</Portal>
</>
)
}
Async shared function (one fetch, instant reuse when new component mounts):
import { useEffect, useState } from 'react';
import { useSharedFunction } from 'react-shared-states';
// Any async callback you want to share
const fetchCurrentUser = () => fetch('/api/me').then(r => r.json());
function UserHeader(){
const { state, trigger } = useSharedFunction('current-user', fetchCurrentUser);
useEffect(() => {
trigger();
}, []);
if(state.isLoading && !state.results) return <p>Loading user...</p>;
if(state.error) return <p style={{color:'red'}}>Failed.</p>;
return <h1>{state.results.name}</h1>;
}
function UserDetails(){
const { state, trigger } = useSharedFunction('current-user', fetchCurrentUser);
// This effect will run when the component appears later, but fetch is already cached so trigger does nothing.
useEffect(() => {
trigger();
}, []);
if(state.isLoading && !state.results) return <p>Loading user...</p>; // this will not happen, cuz we already have the shared result
if(state.error) return <p style={{color:'red'}}>Failed.</p>; // this will not happen, cuz we already have the shared result
return <pre>{JSON.stringify(state.results, null, 2)}</pre>;
}
export default function App(){
const [showDetails, setShowDetails] = useState(false);
return (
<div>
<UserHeader/>
<button onClick={()=>setShowDetails(s=>!s)}>
{showDetails ? 'Hide details' : 'Show details'}
</button>
{showDetails && <UserDetails/>}
</div>
);
}
// If you need to force a refetch somewhere:
// const { forceTrigger } = useSharedFunction('current-user', fetchCurrentUser);
// forceTrigger(); // bypass cache & re-run
Concept | Summary |
---|---|
Global by default | No provider necessary. Same key => shared state. |
Scoping | Wrap with SharedStatesProvider to isolate. Nearest provider wins. |
Named scopes | scopeName prop lets distant providers sync (same name โ same bucket). Unnamed providers autoโgenerate a random isolated name. |
Manual override | Third param in useSharedState / useSharedFunction / useSharedSubscription enforces a specific scope ignoring tree search. |
Shared functions | Encapsulate async logic: single flight + cached result + error + isLoading + optโin refresh. |
Shared subscriptions | Real-time data streams: automatic cleanup + shared connections + error + isLoading + subscription state. |
Static APIs | Access state/functions/subscriptions outside components (sharedStatesApi , sharedFunctionsApi , sharedSubscriptionsApi ). |
Signature: const [value, setValue] = useSharedState(key, initialValue, scopeName?);
Behavior:
- First hook call (per key + scope) seeds with
initialValue
. - Subsequent mounts with same key+scope ignore their
initialValue
(consistent source of truth). - Setter accepts either value or updater
(prev)=>next
. - React batching + equality check: listeners fire only when the value reference actually changes.
- Global theme
const [theme, setTheme] = useSharedState('theme', 'light');
- Isolated wizard progress
<SharedStatesProvider> <Wizard/> </SharedStatesProvider>
- Forcing crossโportal sync
<SharedStatesProvider scopeName="nav" children={<PrimaryNav/>} /> <Portal> <SharedStatesProvider scopeName="nav" children={<MobileNav/>} /> </Portal>
- Overriding nearest provider
// Even if inside a provider, this explicitly binds to global const [flag, setFlag] = useSharedState('feature-x-enabled', false, '_global');
Signature:
const { state, trigger, forceTrigger, clear } = useSharedFunction(key, asyncFn, scopeName?);
state
shape: { results?: T; isLoading: boolean; error?: unknown }
Semantics:
- First
trigger()
(implicit or manual) runs the function; subsequent calls do nothing while loading or after success (cached) unless youforceTrigger()
. - Multiple components with the same key+scope share one execution + result.
clear()
deletes the cache (next trigger re-runs).- You decide when to invoke
trigger
(e.g. on mount, on button click, when dependencies change, etc.).
function Profile({id}:{id:string}){
const { state, trigger } = useSharedFunction(`profile-${id}`, () => fetch(`/api/p/${id}`).then(r=>r.json()));
if(!state.results && !state.isLoading) trigger();
if(state.isLoading) return <p>Loading...</p>;
return <pre>{JSON.stringify(state.results,null,2)}</pre>
}
const { state, forceTrigger } = useSharedFunction('server-time', () => fetch('/time').then(r=>r.text()));
const refresh = () => forceTrigger();
Perfect for Firebase listeners, WebSocket connections, Server-Sent Events, or any streaming data source that needs cleanup.
Signature:
const { state, trigger, unsubscribe } = useSharedSubscription(key, subscriber, scopeName?);
state
shape: { data?: T; isLoading: boolean; error?: unknown; subscribed: boolean }
The subscriber
function receives three callbacks:
set(data)
: Update the shared dataonError(error)
: Handle errorsonCompletion()
: Mark loading as complete- Returns: Optional cleanup function (called on unsubscribe/unmount)
import { useEffect } from 'react';
import { onSnapshot, doc } from 'firebase/firestore';
import { useSharedSubscription } from 'react-shared-states';
import { db } from './firebase-config'; // your Firebase config
function UserProfile({ userId }: { userId: string }) {
const { state, trigger, unsubscribe } = useSharedSubscription(
`user-${userId}`,
async (set, onError, onCompletion) => {
const userRef = doc(db, 'users', userId);
// Set up the real-time listener
const unsubscribe = onSnapshot(
userRef,
(snapshot) => {
if (snapshot.exists()) {
set({ id: snapshot.id, ...snapshot.data() });
} else {
set(null);
}
},
onError,
onCompletion
);
// Return cleanup function
return unsubscribe;
}
);
// Start listening when component mounts
useEffect(() => {
trigger();
}, []);
if (state.isLoading) return <div>Connecting...</div>;
if (state.error) return <div>Error: {state.error.message}</div>;
if (!state.data) return <div>User not found</div>;
return (
<div>
<h1>{state.data.name}</h1>
<p>{state.data.email}</p>
<button onClick={unsubscribe}>Stop listening</button>
</div>
);
}
import { useEffect } from 'react';
import { useSharedSubscription } from 'react-shared-states';
function ChatRoom({ roomId }: { roomId: string }) {
const { state, trigger } = useSharedSubscription(
`chat-${roomId}`,
(set, onError, onCompletion) => {
const ws = new WebSocket(`ws://chat-server/${roomId}`);
ws.onopen = () => onCompletion();
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
set(prev => [...(prev || []), message]);
};
ws.onerror = onError;
return () => ws.close();
}
);
useEffect(() => {
trigger();
}, []);
return (
<div>
{state.isLoading && <p>Connecting to chat...</p>}
{state.error && <p>Connection failed</p>}
<div>
{state.data?.map(msg => (
<div key={msg.id}>{msg.text}</div>
))}
</div>
</div>
);
}
import { useEffect } from 'react';
import { useSharedSubscription } from 'react-shared-states';
function LiveUpdates() {
const { state, trigger } = useSharedSubscription(
'live-updates',
(set, onError, onCompletion) => {
const eventSource = new EventSource('/api/live-updates');
eventSource.onopen = () => onCompletion();
eventSource.onmessage = (event) => {
set(JSON.parse(event.data));
};
eventSource.onerror = onError;
return () => eventSource.close();
}
);
useEffect(() => {
trigger();
}, []);
return <div>Latest: {JSON.stringify(state.data)}</div>;
}
Subscription semantics:
- First
trigger()
establishes the subscription; subsequent calls do nothing if already subscribed. - Multiple components with the same key+scope share one subscription + data stream.
unsubscribe()
closes the connection and clears the subscribed state.- Automatic cleanup on component unmount when no other components are listening.
- Components mounting later instantly get the latest
data
without re-subscribing.
Useful for SSR hydration, event listeners, debugging, imperative workflows.
import { sharedStatesApi, sharedFunctionsApi, sharedSubscriptionsApi } from 'react-shared-states';
// Preload state
sharedStatesApi.set('bootstrap-data', { user: {...} });
// Read later
const user = sharedStatesApi.get('bootstrap-data');
// Inspect all
console.log(sharedStatesApi.getAll()); // Map with prefixed keys
// For shared functions
const fnState = sharedFunctionsApi.get('profile-123');
// For shared subscriptions
const subState = sharedSubscriptionsApi.get('live-chat');
API | Methods |
---|---|
sharedStatesApi |
get(key, scope?) , set(key,val,scope?) , has , clear , clearAll , getAll() |
sharedFunctionsApi |
get(key, scope?) (returns fn state), set , has , clear , clearAll , getAll() |
sharedSubscriptionsApi |
get(key, scope?) (returns sub state), set , has , clear , clearAll , getAll() |
scope
defaults to "_global"
. Internally keys are stored as ${scope}_${key}
.
Resolution order used inside hooks:
- Explicit 3rd parameter (
scopeName
) - Nearest
SharedStatesProvider
above the component - The implicit global scope (
_global
)
Unnamed providers autoโgenerate a random scope name: each mount = isolated island.
Two providers sharing the same scopeName
act as a single logical scope even if they are disjoint in the tree (great for portals / microfrontends).
Criterion | react-shared-states | Redux Toolkit | Zustand |
---|---|---|---|
Setup | Install & call hook | Slice + store config | Create store function |
Global state | Yes (by key) | Yes | Yes |
Scoped state | Built-in (providers + names + overrides) | Needs custom logic | Needs multiple stores / contexts |
Async helper | useSharedFunction (cache + status) |
Thunks / RTK Query | Manual or middleware |
Boilerplate | Near zero | Moderate | Low |
Static access | Yes (APIs) | Yes (store) | Yes (store) |
Learning curve | Minutes | Higher | Low |
- Use static APIs to assert state after component interactions.
sharedStatesApi.clearAll()
,sharedFunctionsApi.clearAll()
,sharedSubscriptionsApi.clearAll()
inafterEach
to isolate tests.- For async functions: trigger once, await UI stabilization, assert
results
present. - For subscriptions: mock the subscription source (Firebase, WebSocket, etc.) and verify data flow.
Q: How do I reset a single shared state?
sharedStatesApi.clear('key')
or inside component: call a setter with the initial value.
Q: Can I pre-hydrate data on the server?
Yes. Call sharedStatesApi.set(...)
during bootstrap, then first client hook usage will pick it up.
Q: How do I avoid accidental key collisions?
Prefix keys by domain (e.g. user:profile
, cart:items
) or rely on provider scoping.
Q: Why is my async function not re-running?
It's cached. Use forceTrigger()
or clear()
.
Q: How do I handle subscription cleanup?
Subscriptions auto-cleanup when no components are listening. You can also manually call unsubscribe()
.
Q: Can I use it with Suspense?
Currently no built-in Suspense wrappers; wrap useSharedFunction
yourself if desired.
Returns [value, setValue]
.
Returns { state, trigger, forceTrigger, clear }
.
Returns { state, trigger, unsubscribe }
.
Wrap children; optional scopeName
(string). If omitted a random unique one is generated.
sharedStatesApi
, sharedFunctionsApi
, sharedSubscriptionsApi
(see earlier table).
We welcome contributions!
If you'd like to improve react-shared-states
,
feel free to open an issue or submit a pull request.
Inspired by React's built-in primitives and the ergonomics of modern lightweight state libraries. Thanks to early adopters for feedback.