Lightweight signal-based React context store
npm install @thefoxieflow/signalctxA tiny, signal-based state utility for React that solves the useContext re-render problem usinguseSyncExternalStore.
If you’ve ever had this issue:
``ts`
{
count, book;
}
// updating count re-renders book components 😡
signalctx is designed specifically to fix that.
---
React Context subscribes to the entire value.
`tsx`
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.
---
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.
---
- ⚡ 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
---
`bash`
npm install @thefoxieflow/signalctx
> Peer dependency: React 18+
---
Context does not store state.
It stores a stable signal reference.
`tsx`
The state lives outside React, and components subscribe directly to the signal.
---
A signal is:
- A function that returns state
- Can be subscribed to
- Can be updated imperatively
`ts
type Signal
(): 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
};
`
---
Creates a low-level signal.
`ts
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);
`
---
Subscribe to a signal.
`tsx`
const count = useValue(store, (s) => s.count);
- Uses useSyncExternalStore
- Re-renders only when the selected value changes
- Selector is optional
---
Returns a setter function.
`ts
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:
`ts
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.
---
Creates a context-backed signal store hook.
`ts
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 state
- useAppCtx.Provider - Context provider component
- useAppCtx.useSet(selector, options) - Hook to get setter function
- useAppCtx.useSignal(options) - Hook to access raw signal underlying the context
---
`tsx
// use default initial value from useAppCtx
type Props = {
children: React.ReactNode;
};
export function AppCtxProvider({ children }: Props) {
return
}
// overwrite value
export function AppCtxProvider({ children }: Props) {
return (
count: 10,
book: { title: "Brave New World" },
}}
>
{children}
);
}
`
`tsx`
---
`tsx
function Count() {
const count = useAppCtx((s) => s.count);
return {count};
}
function Book() {
const book = useAppCtx((s) => s.book);
return
---
$3
`tsx
function Increment() {
const setCount = useAppCtx.useSet((s) => s); return (
onClick={() =>
setCount((s) => {
s.count++;
})
}
>
+
);
}
`$3
`tsx
const signalWithTraceSet = (
init: () => T
) => {
const core = newSignal(init); const signal: Signal = () => core();
signal.reset = core.reset;
signal.notify = core.notify;
signal.on = core.on;
// set interceptor
signal.set = (action: SetAction) => {
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
-
useSyncExternalStore compares selected snapshots
- Only changed selectors trigger re-rendersThis 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:
`ts
const { count } = useAppCtx((s) => s);
`✅ Good:
`ts
const count = useAppCtx((s) => s.count);
`---
🧩 Multiple Stores
You can create isolated stores using
name.`tsx
type Props = {
children: React.ReactNode;
name?: string;
initialValue?: { count: number; book: { title: string } };
};export function AppCtxProvider({ children, name, initialValue }: Props) {
return (
{children}
);
}
`$3
`tsx
{/ useAppCtx(s => s.book) is from storeA /}
name="storeB"
initialValue={{ count: 5, book: { title: "B" } }}
>
{/ useAppCtx(s => s.book) is from storeB /}
{/ useAppCtx(s => s.book, { name: "storeA" }) is from storeA /}
;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.
`ts
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`.