@thefoxieflow/signalctx
v1.0.5
Published
Lightweight signal-based React context store
Maintainers
Readme
signalctx
A tiny, signal-based state utility for React that solves the useContext re-render problem using
useSyncExternalStore.
If you’ve ever had this issue:
{
count, book;
}
// updating count re-renders book components 😡signalctx is designed specifically to fix that.
✨ Why signal-ctx?
❌ The Problem with useContext
React Context subscribes to the entire value.
const { book } = useContext(StoreCtx);When any property changes, every consumer re-renders — even if they don’t use it.
This is not a bug. Context has no selector mechanism.
✅ The Solution
signal-ctx:
- Moves state outside React
- Uses external subscriptions
- Allows selector-based updates
So only the components that actually use the changed data re-render.
✨ Features
- ⚡ Signal-style state container
- 🎯 Selector-based subscriptions
- 🧵 React 18 concurrent-safe
- 🧩 Context-backed but not context-driven
- 📦 Very small bundle size
- 🌳 Tree-shakable
- 🧠 Explicit and predictable
📦 Installation
npm install @thefoxieflow/signalctxPeer dependency: React 18+
🧠 Core Idea
Context does not store state.
It stores a stable signal reference.
<Provider value={store} />The state lives outside React, and components subscribe directly to the signal.
🔹 Signal
A signal is:
- A function that returns state
- Can be subscribed to
- Can be updated imperatively
type Signal<T extends object> = {
(): T; // get state
// add listener
on(fn: Subscriber): () => void;
// notify all listeners
notify(): void;
// reset to initial value
reset(): void;
// update state
set(action: SetAction<T>): void;
};🔹 Low-Level Functions
newSignal(init)
Creates a low-level signal.
const signal = newSignal({ count: 0 });
const state = signal(); // get state { count: 0 }
signal.on(() => console.log("changed"));
setInterval(() => {
signal.set((s) => {
s.count++;
});
// will trigger signal.on listeners
signal.notify();
}, 2000);🔹 React Hooks
useValue(store, selector?)
Subscribe to a signal.
const count = useValue(store, (s) => s.count);- Uses
useSyncExternalStore - Re-renders only when the selected value changes
- Selector is optional
useSet(store, selector?)
Returns a setter function.
const set = useSet(store);
// update entire state
const prev = store();
set({ ...prev, count: prev.count + 1 });
// or update partially
set((s) => {
s.count++;
});Scoped update:
const store = newSignal(() => ({
book: { title: "1984", page: 1 },
user: { name: "Alice" },
}));
// book must be object for selector
const setBook = useSet(store, (s) => s.book);
// update an object
const setBook = () => {
setBook({
title: "1999",
page: 10,
});
};
// or update partially
setBook((b) => {
b.title = "1999";
});⚠️ Updates are mutation-based. Spread manually if you want immutability.
🔹 Context-Based API
createCtx(init)
Creates a context-backed signal store hook.
import { createCtx } from "@thefoxieflow/signalctx";
export const useAppCtx = createCtx(() => ({
count: 0,
book: { title: "1984" },
}));The returned function has these properties:
useAppCtx(selector, options)- Hook to select stateuseAppCtx.Provider- Context provider componentuseAppCtx.useSet(selector, options)- Hook to get setter functionuseAppCtx.useSignal(options)- Hook to access raw signal underlying the context
🚀 Usage
1. Create a Provider
// use default initial value from useAppCtx
type Props = {
children: React.ReactNode;
};
export function AppCtxProvider({ children }: Props) {
return <useAppCtx.Provider>{children}</useAppCtx.Provider>;
}
// overwrite value
export function AppCtxProvider({ children }: Props) {
return (
<useAppCtx.Provider
value={{
count: 10,
book: { title: "Brave New World" },
}}
>
{children}
</useAppCtx.Provider>
);
}<AppCtxProvider>
<App />
</AppCtxProvider>2. Read only what you need
function Count() {
const count = useAppCtx((s) => s.count);
return <div>{count}</div>;
}
function Book() {
const book = useAppCtx((s) => s.book);
return <div>{book.title}</div>;
}3. Update state
function Increment() {
const setCount = useAppCtx.useSet((s) => s);
return (
<button
onClick={() =>
setCount((s) => {
s.count++;
})
}
>
+
</button>
);
}4. Custom signal for additional logic
const signalWithTraceSet = <T extends object & { traceSet: number }>(
init: () => T
) => {
const core = newSignal(init);
const signal: Signal<T> = () => core();
signal.reset = core.reset;
signal.notify = core.notify;
signal.on = core.on;
// set interceptor
signal.set = (action: SetAction<T>) => {
console.log("before set", core().traceSet);
core.set(action);
core().traceSet += 1;
console.log("after set", core().traceSet);
};
return signal;
};
const useHelloCtx = createCtx(
() => ({ traceSet: 0, text: "hello" }),
signalWithTraceSet
);✅ Updating count does NOT re-render Book.
🧩 Why This Works
- Context value never changes
- React does not re-render on context updates
useSyncExternalStorecompares selected snapshots- Only changed selectors trigger re-renders
This is the same model used by:
- Redux
useSelector - Zustand selectors
- React’s official external store docs
⚠️ Important Rule
Never destructure the entire state. Always select the smallest possible slice.
❌ Bad:
const { count } = useAppCtx((s) => s);✅ Good:
const count = useAppCtx((s) => s.count);🧩 Multiple Stores
You can create isolated stores using name.
type Props = {
children: React.ReactNode;
name?: string;
initialValue?: { count: number; book: { title: string } };
};
export function AppCtxProvider({ children, name, initialValue }: Props) {
return (
<useAppCtx.Provider value={initialValue} name={name}>
{children}
</useAppCtx.Provider>
);
}Usage
<AppCtxProvider name="storeA" initialValue={{ count: 1, book: { title: "A" } }}>
{/* useAppCtx(s => s.book) is from storeA */}
<AppA />
<AppCtxProvider
name="storeB"
initialValue={{ count: 5, book: { title: "B" } }}
>
{/* useAppCtx(s => s.book) is from storeB */}
{/* useAppCtx(s => s.book, { name: "storeA" }) is from storeA */}
<AppB />
</AppCtxProvider>
</AppCtxProvider>;
function AppB() {
// Read from parent storeB, book.title = "B"
const currentBook = useAppCtx((s) => s.book); // or useAppCtx(s => s.book, { name: "storeB" })
const layerAbook = useAppCtx((s) => s.book, { name: "storeA" }); // book.title = "A"
// AppB want to change data in context StoreA layer
const setLayerAbook = useAppCtx.useSet((s) => s.book, {
name: "storeA",
});
const handleSetLayerABook = (text) => {
setLayerAbook((b) => {
if (b.title !== "A") {
console.error("title in storeA should be A");
}
b.title = text;
});
};
}Each store is independent.
🌐 Server-Side Rendering (SSR)
Signal Ctx is SSR-safe.
- Uses
useSyncExternalStore - Identical snapshot logic on server & client
- No shared global state between requests
⚠️ Caveats
- No middleware
- No devtools
- No persistence
- Mutation-based updates by design
Best suited for:
- UI state
- Lightweight global stores
- flexible shared state
🧪 TypeScript
Fully typed with generics and inferred selectors.
const count = useAppCtx((s) => s.count); // number📄 License
MIT
⭐ Philosophy
signalctx is intentionally small.
It favors:
- Explicit ownership
- Predictable updates
- Minimal abstraction
If you understand React, you understand signalctx.
