Back to Projects

Signal Ctx

A tiny, signal-based state utility for React that solves the useContext re-render problem using useSyncExternalStore.

Team Size: 1
Duration: 2026-01-13–2026-01-14
View on GitHubView on NPM

Tech Stack

reactjs iconReact

✨ 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 /lite core, zero runtime dependencies (see Bundle Size)
  • 🪶 Optional shallow comparator for derived-object selectors
  • 🌳 Tree-shakable ESM + CJS + TypeScript definitions
  • 🧠 Explicit and predictable

📦 Installation

bash
npm install @thefoxieflow/signalctx

Peer dependency: React 18+

Two entry points:

ts
import { createCtx } from "@thefoxieflow/signalctx" // full: context, Provider, named stores
import { newSignal, useValue, useSet, shallow } from "@thefoxieflow/signalctx/lite" // core only

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):

VariantSize
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 comparator528 B

Compared to its relatives (same methodology):

LibraryMin + gzipRuntime deps
signalctx/lite431 B ✅ beats zustand0
zustand~490 B0
signalctx full783 B0
valtio~2.6 kB1 (proxy-compare)
react-redux~3.8 kB2 (+ redux)
jotai~4.0 kB0

Notes:

  • Ships ESM + CJS + TypeScript definitions in a single dist folder
  • react is 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 + useReducer is free in bytes, but every consumer re-renders on any state change — the exact problem signalctx solves

🧠 Core Idea

Context does not store state.

It stores a stable signal reference.

tsx
<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
ts
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
}

New in v2: every set stores a shallow clone of the state, so useSyncExternalStore always sees a fresh reference. Mutate prev inside updaters — and replace nested objects when subscribers select them.


🔹 Low-Level Functions

newSignal(init)

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)

🔹 React Hooks

useValue(store, selector?, isEqual?)

Subscribe to a signal.

tsx
const count = useValue(signal, s => s.count)

// derived object? keep the snapshot stable with the built-in shallow comparator
const summary = useValue(signal, s => ({ done: s.done, total: s.total }), shallow)
  • Uses useSyncExternalStore
  • Re-renders only when the selected value changes
  • Selector is optional
  • isEqual (e.g. the exported shallow) 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: useSet no longer takes a selector — it always operates on the whole state.

ts
const set = useSet(signal)

// replace the entire state
set({ count: 1 })

// or update partially
set(s => {
  s.count++
})

🔹 Context-Based API

createCtx(init)

Creates a context-backed signal store hook.

ts
import { createCtx } from "@thefoxieflow/signalctx"

export const useAppCtx = createCtx(() => ({
  count: 0,
  book: { title: "1984" },
}))

Scoped updates (v2 pattern)

Get the full-state setter and mutate — replace a nested object when its subscribers select the slice:

tsx
const useLibraryCtx = createCtx(() => ({
  book: { title: "1984", page: 1 },
  user: { name: "Alice" },
}))

function BookEditor() {
  const setStore = useLibraryCtx.useSet()

  // mutate in place — fine for primitive readers
  setStore(s => {
    s.book.title = "1999"
    s.book.page = 10
  })

  // or replace the nested object so `useLibraryCtx(s => s.book)` subscribers re-render
  setStore(s => {
    s.book = { ...s.book, title: "1999" }
  })
}

⚠️ 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.

ts
import { createCtx } from "@thefoxieflow/signalctx"

export const useAppCtx = createCtx(() => ({
  count: 0,
  book: { title: "1984" },
}))

The returned function has these properties:

  • useAppCtx(selector?, options?, isEqual?) - Hook to select state (selector optional)
  • useAppCtx.Provider - Context provider component (value / name are 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

tsx
// 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>
  )
}
tsx
<AppCtxProvider>
  <App />
</AppCtxProvider>

2. Read only what you need

tsx
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

tsx
function Increment() {
  const setCount = useAppCtx.useSet()

  return (
    <button
      onClick={() =>
        setCount(s => {
          s.count++
        })
      }
    >
      +
    </button>
  )
}

4. Custom signal for additional logic

tsx
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
  • useSyncExternalStore compares 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:

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 (
    <useAppCtx.Provider value={initialValue} name={name}>
      {children}
    </useAppCtx.Provider>
  )
}

Usage

tsx
;<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 setLayerAStore = useAppCtx.useSet({ name: "storeA" })

  const handleSetLayerABook = (text: string) => {
    setLayerAStore(s => {
      if (s.book.title !== "A") {
        console.error("title in storeA should be A")
      }

      s.book = { ...s.book, title: text }
    })
  }
}

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

  • useSet lost its selectoruseCtx.useSet(s => s.slice) becomes useCtx.useSet(), then mutate s.slice.… inside the action
  • State snapshots are shallow-cloned on every set; replace nested objects when subscribers select them
  • New: optional isEqual comparator on useValue / the context hook (pass the built-in shallow for derived objects)
  • New: @thefoxieflow/signalctx/lite entry point (~431 B core)

🧪 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.