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
  • 🧩 Context-backed but not context-driven
  • 📦 Very small bundle size
  • 🌳 Tree-shakable
  • 🧠 Explicit and predictable

📦 Installation

bash
npm install @thefoxieflow/signalctx

Peer dependency: React 18+


🧠 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
}

🔹 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?)

Subscribe to a signal.

tsx
const count = useValue(signal, s => s.count)
  • Uses useSyncExternalStore
  • Re-renders only when the selected value changes
  • Selector is optional

useSet(store, selector?)

Returns a setter function.

ts
const set = useSet(signal)

// update entire state
const prev = signal()
set({ ...prev, count: prev.count + 1 })

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

Scoped update:

ts
const signal = newSignal(() => ({
  book: { title: "1984", page: 1 },
  user: { name: "Alice" },
}))
// book must be object for selector
const setBook = useSet(signal, 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.

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

🚀 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(s => s)

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