Signal Ctx
A tiny, signal-based state utility for React that solves the useContext re-render problem using useSyncExternalStore.
Tech Stack
✨ Features
- ⚡ Signal-style state container
- 🎯 Selector-based subscriptions
- 🧵 React 18 concurrent-safe (StrictMode-safe named stores)
- 🧩 Context-backed but not context-driven
- 📦 783 B min+gzip full build · 431 B
/litecore, zero runtime dependencies (see Bundle Size) - 🪶 Optional
shallowcomparator for derived-object selectors - 🌳 Tree-shakable ESM + CJS + TypeScript definitions
- 🧠 Explicit and predictable
📦 Installation
Peer dependency: React 18+
Two entry points:
Use /lite when you only need signals without the context layer — it tree-shakes down to ~431 B.
📏 Bundle Size
Real measured numbers, minified + gzipped (August 2026):
| Variant | Size |
|---|---|
signalctx v2.0.0 (before) | 834 B |
signalctx v2.1.0 full (createCtx, Provider, named stores) | 783 B |
signalctx/lite core (tree-shaken) | 431 B ✅ |
/lite core + optional shallow comparator | 528 B |
Compared to its relatives (same methodology):
| Library | Min + gzip | Runtime deps |
|---|---|---|
signalctx/lite | 431 B ✅ beats zustand | 0 |
zustand | ~490 B | 0 |
signalctx full | 783 B | 0 |
valtio | ~2.6 kB | 1 (proxy-compare) |
react-redux | ~3.8 kB | 2 (+ redux) |
jotai | ~4.0 kB | 0 |
Notes:
- Ships ESM + CJS + TypeScript definitions in a single
distfolder reactis a peer dependency only — nothing else ships- The v2 rewrite shipped smaller than v1 while adding
shallow, snapshot cloning, and StrictMode-safe named stores - React Context +
useReduceris free in bytes, but every consumer re-renders on any state change — the exact problemsignalctxsolves
🧠 Core Idea
Context does not store state.
It stores a stable signal reference.
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
New in v2: every
setstores a shallow clone of the state, souseSyncExternalStorealways sees a fresh reference. Mutateprevinside updaters — and replace nested objects when subscribers select them.
🔹 Low-Level Functions
newSignal(init)
Creates a low-level signal.
🔹 React Hooks
useValue(store, selector?, isEqual?)
Subscribe to a signal.
- Uses
useSyncExternalStore - Re-renders only when the selected value changes
- Selector is optional
isEqual(e.g. the exportedshallow) keeps derived-object snapshots referentially stable
useSet(store)
Returns a setter function for the full state. It applies the action and notifies subscribers.
⚠️ Breaking change in v2:
useSetno longer takes a selector — it always operates on the whole state.
🔹 Context-Based API
createCtx(init)
Creates a context-backed signal store hook.
Scoped updates (v2 pattern)
Get the full-state setter and mutate — replace a nested object when its subscribers select the slice:
⚠️ Updates are mutation-based with a shallow clone stored per set. Spread nested objects manually if you need immutability.
🔹 Context-Based API
createCtx(init)
Creates a context-backed signal store hook.
The returned function has these properties:
useAppCtx(selector?, options?, isEqual?)- Hook to select state (selector optional)useAppCtx.Provider- Context provider component (value/nameare initial-only)useAppCtx.useSet(options?)- Hook returning the full-state setter (v2: no selector)useAppCtx.useSignal(options?)- Hook to access raw signal underlying the context
🚀 Usage
1. Create a Provider
2. Read only what you need
3. Update state
4. Custom signal for additional logic
✅ 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:
✅ Good:
🧩 Multiple Stores
You can create isolated stores using name.
Usage
Each store is independent. In v2 the named-store registry re-registers idempotently, so it survives React StrictMode's unmount/remount replay.
🌐 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 — shallow clone per
set, replace nested objects for nested subscribers
Best suited for:
- UI state
- Lightweight global stores
- flexible shared state
🆕 Migrating from v1
useSetlost its selector —useCtx.useSet(s => s.slice)becomesuseCtx.useSet(), then mutates.slice.…inside the action- State snapshots are shallow-cloned on every
set; replace nested objects when subscribers select them - New: optional
isEqualcomparator onuseValue/ the context hook (pass the built-inshallowfor derived objects) - New:
@thefoxieflow/signalctx/liteentry point (~431 B core)
🧪 TypeScript
Fully typed with generics and inferred selectors.
📄 License
MIT
⭐ Philosophy
signalctx is intentionally small.
It favors:
- Explicit ownership
- Predictable updates
- Minimal abstraction
If you understand React, you understand signalctx.