rxor

April 3, 2026 · View on GitHub

Fine-grained reactive signals for React.

Zero dependency core. ~2KB gzipped. Works with React 18+ and React 19.

rxor brings reactive signals to React, inspired by Angular Signals, Vue 3 ref/computed, and SolidJS.


Installation

npm install rxor
# or
pnpm add rxor
# or
yarn add rxor

Requirements: React 18.0+ and TypeScript 5.0+

Compatibility: Works with any React UI library (Mantine, MUI, Chakra, Ant Design, etc.).


Quick start

import { signal, computed, SignalValue } from 'rxor'

const count = signal(0)
const doubled = computed(() => count.value * 2)

function Counter() {
  // This component renders ONCE and never re-renders
  return (
    <div>
      <p>Count: <SignalValue signal={count} /></p>
      <p>Doubled: <SignalValue signal={doubled} /></p>
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

When count changes, only the two <SignalValue> texts update. The Counter function never re-runs. The <button> never re-renders.


Two ways to read a signal in React

rxor provides two approaches. Choose based on your needs.

useSignal — simple, the component re-renders

import { signal, useSignal } from 'rxor'

const name = signal("John")

function Greeting() {
  const n = useSignal(name)  // the component re-renders when name changes
  return <p>Hello, {n}!</p>
}

Use useSignal when you need the value as a variable (for logic, props, conditions, loops).

<SignalValue> — fine-grained, the component never re-renders

import { signal, SignalValue } from 'rxor'

const name = signal("John")

function Greeting() {
  // No hook, no re-render. This function runs ONCE.
  return <p>Hello, <SignalValue signal={name} />!</p>
}

Use <SignalValue> when you just need to display a value (text, number). It creates a micro-component that updates independently.

Do not mix both for the same signal

// BAD — redundant, the component re-renders AND SignalValue re-renders
const n = useSignal(name)
<p><SignalValue signal={name} /></p>

// GOOD — pick one
const n = useSignal(name)        // Option A: component re-renders
<p>{n}</p>

<p><SignalValue signal={name} /></p>  // Option B: only the text re-renders

When to use which?

SituationUse
Display a text/number in JSX<SignalValue>
Pass a value as prop to a componentuseSignal
Use a value in a condition or loopuseSignal
Maximum performance, zero re-renders<SignalValue>
Simple and quickuseSignal

Core API

signal<T>(initial): Signal<T>

A reactive container. Reading .value tracks dependencies. Writing .value notifies subscribers.

import { signal } from 'rxor/core'

const name = signal("John")

name.value          // read: "John"
name.value = "Jane" // write: notifies all subscribers
name.peek()         // read without tracking: "Jane"

Supported types

// Primitives
const count = signal(0)                       // Signal<number>
const label = signal("hello")                 // Signal<string>
const active = signal(true)                   // Signal<boolean>
const maybe = signal<string | null>(null)     // Signal<string | null>

// Objects — each property is tracked independently
const user = signal({ name: "John", age: 25 })
user.value.name = "Jane"  // notifies only watchers of .name, not .age

// Arrays — mutations are intercepted
const list = signal([1, 2, 3])
list.value.push(4)        // notifies watchers
list.value.splice(0, 1)   // notifies watchers
list.value[0] = 99        // notifies watchers

// Map
const cache = signal(new Map<string, number>())
cache.value.set("key", 42)    // notifies watchers
cache.value.delete("key")     // notifies watchers

// Set
const tags = signal(new Set<string>())
tags.value.add("urgent")      // notifies watchers
tags.value.delete("urgent")   // notifies watchers

Deep reactivity

When a signal holds an object, each property is tracked independently:

const state = signal({ a: { x: 1 }, b: { y: 2 } })

effect(() => {
  console.log(state.value.a.x)  // tracks only a.x
})

state.value.b.y = 99  // does NOT re-run the effect
state.value.a.x = 10  // re-runs the effect
MethodDescription
.valueRead (with tracking) or write the value
.peek()Read without creating a dependency
.subscribe(cb)Listen for changes, returns an unsubscribe function

computed<T>(fn): Computed<T>

A derived value that recalculates automatically when its dependencies change.

import { signal, computed } from 'rxor/core'

const price = signal(100)
const tax = signal(0.2)
const total = computed(() => price.value * (1 + tax.value))

total.value   // 120
price.value = 200
total.value   // 240 — recalculated automatically

Key behaviors:

  • Lazy — does not compute until .value is read
  • Cached — does not recompute if dependencies haven't changed
  • Readonly — setting .value throws an error
  • Nested — a computed can depend on other computeds
const firstName = signal("John")
const lastName = signal("Doe")
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const greeting = computed(() => `Hello, ${fullName.value}!`)

greeting.value   // "Hello, John Doe!"
firstName.value = "Jane"
greeting.value   // "Hello, Jane Doe!"

effect(fn): () => void

Runs a function immediately, then re-runs it whenever its dependencies change. Returns a dispose function.

import { signal, effect } from 'rxor/core'

const count = signal(0)

const dispose = effect(() => {
  console.log("Count:", count.value)
})
// logs: "Count: 0"

count.value = 5
// logs: "Count: 5"

dispose()
count.value = 10  // nothing happens

Replaces most useEffect usage

// Sync with localStorage
effect(() => {
  localStorage.setItem("theme", theme.value)
})

// Update document title
effect(() => {
  document.title = `(${unreadCount.value}) Messages`
})

// Log changes
effect(() => {
  console.log("User changed:", user.value)
})

Cleanup

Return a function from the effect for cleanup before each re-run:

const userId = signal(1)

effect(() => {
  const ws = new WebSocket(`/ws/user/${userId.value}`)
  return () => ws.close()  // cleanup before re-run
})

Dynamic dependencies

Effects automatically re-track dependencies on each run:

const toggle = signal(true)
const a = signal("A")
const b = signal("B")

effect(() => {
  console.log(toggle.value ? a.value : b.value)
})

b.value = "B2"       // does NOT re-run (toggle is true, b not tracked)
toggle.value = false  // re-runs, logs "B2"
a.value = "A2"        // does NOT re-run (toggle is false, a not tracked)

batch(fn)

Groups multiple signal writes into a single notification:

import { signal, effect, batch } from 'rxor/core'

const a = signal(1)
const b = signal(2)

effect(() => {
  console.log(a.value + b.value)
})
// logs: 3

batch(() => {
  a.value = 10
  b.value = 20
})
// logs: 30 (once, not twice)

untracked(fn)

Read signal values without creating dependencies:

import { signal, effect, untracked } from 'rxor/core'

const count = signal(0)
const label = signal("hello")

effect(() => {
  const c = count.value                       // tracked
  const l = untracked(() => label.value)      // NOT tracked
  console.log(c, l)
})

label.value = "world"  // does NOT re-run the effect
count.value = 1        // re-runs the effect

React hooks

useSignal<T>(signal): T

Subscribe to a signal. The component re-renders when the signal changes.

Uses useSyncExternalStore — concurrent mode safe and SSR compatible.

import { signal, computed, useSignal } from 'rxor'

const count = signal(0)
const doubled = computed(() => count.value * 2)

function Display() {
  const c = useSignal(count)
  const d = useSignal(doubled)
  return <p>{c} x2 = {d}</p>
}

useComputed<T>(fn): T

Create a computed inline in a component:

import { signal, useComputed } from 'rxor'

const price = signal(100)
const quantity = signal(3)

function Total() {
  const total = useComputed(() => price.value * quantity.value)
  return <p>Total: {total}</p>
}

Store

createStore(definition)

Group signals, computed, and actions into a typed store:

import { signal, computed, createStore } from 'rxor'

const count = signal(0)

export const counterStore = createStore({
  count,
  doubled: computed(() => count.value * 2),
  increment() { count.value++ },
  decrement() { count.value-- },
  reset() { count.value = 0 },
})

useStore(store, selector)

Subscribe to a specific signal from a store. The component only re-renders when that signal changes:

import { useStore } from 'rxor'
import { counterStore } from '../store/counterStore'

function Counter() {
  const count = useStore(counterStore, s => s.count)
  const doubled = useStore(counterStore, s => s.doubled)

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={counterStore.increment}>+1</button>
    </div>
  )
}

Architecture: Services

rxor does not include a service system in the package. You don't need one — a TypeScript class with signals is a service.

Why services?

In React, business logic often ends up inside components. With rxor, you separate concerns:

  • Service = business logic, data, API calls
  • Component = reads and displays, no logic

This is the same architecture as Angular services, but without decorators or dependency injection framework.

Creating a service

// service/UserService.ts
import { signal, computed } from 'rxor/core'

type User = { id: number; name: string; role: string }

export class UserService {
  // Private state — components cannot write directly
  private readonly _users = signal<User[]>([])
  private readonly _loading = signal(false)
  private readonly _error = signal<string | null>(null)
  private readonly _search = signal("")

  // Public state — read only
  readonly loading = this._loading
  readonly error = this._error
  readonly search = this._search

  readonly users = computed(() => {
    const s = this._search.value.toLowerCase()
    if (!s) return this._users.value
    return this._users.value.filter(u => u.name.toLowerCase().includes(s))
  })

  readonly count = computed(() => this.users.value.length)

  // Actions
  async loadUsers() {
    this._loading.value = true
    this._error.value = null
    try {
      const res = await fetch("/api/users")
      this._users.value = await res.json()
    } catch (e) {
      this._error.value = (e as Error).message
    } finally {
      this._loading.value = false
    }
  }

  addUser(name: string, role: string) {
    this._users.value = [...this._users.value, { id: Date.now(), name, role }]
  }

  removeUser(id: number) {
    this._users.value = this._users.value.filter(u => u.id !== id)
  }

  setSearch(value: string) {
    this._search.value = value
  }
}

Instantiating services

// service/index.ts
import { UserService } from './UserService'
import { AuthService } from './AuthService'

export const userService = new UserService()
export const authService = new AuthService()

Using in a component

The component is "stupid" — it reads and displays, nothing else:

// components/UserTable.tsx
import { useSignal } from 'rxor/react'
import { userService } from '../service'
import { useEffect } from 'react'

export function UserTable() {
  const users = useSignal(userService.users)
  const loading = useSignal(userService.loading)
  const error = useSignal(userService.error)
  const count = useSignal(userService.count)

  useEffect(() => { userService.loadUsers() }, [])

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error: {error}</p>

  return (
    <div>
      <h2>Users ({count})</h2>
      <input
        placeholder="Search..."
        onChange={e => userService.setSearch(e.target.value)}
      />
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} ({user.role})
            <button onClick={() => userService.removeUser(user.id)}>X</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Service with pagination

// service/ProductService.ts
import { signal, computed } from 'rxor/core'

type Product = { id: number; name: string; price: number }

type PaginationMeta = {
  page: number
  pageSize: number
  totalItems: number
  totalPages: number
}

export class ProductService {
  private readonly _products = signal<Product[]>([])
  private readonly _loading = signal(false)
  private readonly _error = signal<string | null>(null)
  private readonly _pagination = signal<PaginationMeta>({
    page: 1,
    pageSize: 20,
    totalItems: 0,
    totalPages: 0,
  })

  readonly products = this._products
  readonly loading = this._loading
  readonly error = this._error
  readonly pagination = this._pagination
  readonly hasNextPage = computed(() => this._pagination.value.page < this._pagination.value.totalPages)
  readonly hasPrevPage = computed(() => this._pagination.value.page > 1)

  async loadProducts(page = 1) {
    this._loading.value = true
    this._error.value = null
    try {
      const size = this._pagination.value.pageSize
      const res = await fetch(`/api/products?page=${page}&size=${size}`)
      const data = await res.json()
      this._products.value = data.items
      this._pagination.value = {
        page: data.page,
        pageSize: data.pageSize,
        totalItems: data.total,
        totalPages: Math.ceil(data.total / data.pageSize),
      }
    } catch (e) {
      this._error.value = (e as Error).message
    } finally {
      this._loading.value = false
    }
  }

  nextPage() {
    if (this.hasNextPage.value) {
      this.loadProducts(this._pagination.value.page + 1)
    }
  }

  prevPage() {
    if (this.hasPrevPage.value) {
      this.loadProducts(this._pagination.value.page - 1)
    }
  }
}
// components/ProductList.tsx
import { useSignal } from 'rxor/react'
import { productService } from '../service'
import { useEffect } from 'react'

export function ProductList() {
  const products = useSignal(productService.products)
  const loading = useSignal(productService.loading)
  const pagination = useSignal(productService.pagination)
  const hasNext = useSignal(productService.hasNextPage)
  const hasPrev = useSignal(productService.hasPrevPage)

  useEffect(() => { productService.loadProducts() }, [])

  if (loading) return <p>Loading...</p>

  return (
    <div>
      <h2>Products (page {pagination.page} / {pagination.totalPages})</h2>

      <ul>
        {products.map(p => (
          <li key={p.id}>{p.name} — {p.price}$</li>
        ))}
      </ul>

      <button disabled={!hasPrev} onClick={() => productService.prevPage()}>Previous</button>
      <span> Page {pagination.page} of {pagination.totalPages} </span>
      <button disabled={!hasNext} onClick={() => productService.nextPage()}>Next</button>
    </div>
  )
}

Service-to-service communication

Services can depend on each other via constructor injection:

// service/OrderService.ts
import { signal } from 'rxor/core'
import type { AuthService } from './AuthService'

export class OrderService {
  constructor(private auth: AuthService) {}

  private readonly _orders = signal([])
  readonly orders = this._orders

  async placeOrder(productId: number) {
    if (!this.auth.isLoggedIn.value) {
      throw new Error("Not authenticated")
    }
    // ...
  }
}
// service/index.ts
import { AuthService } from './AuthService'
import { UserService } from './UserService'
import { OrderService } from './OrderService'

export const authService = new AuthService()
export const userService = new UserService()
export const orderService = new OrderService(authService)
src/
├── service/
│   ├── AuthService.ts
│   ├── UserService.ts
│   ├── ProductService.ts
│   ├── OrderService.ts
│   └── index.ts              ← instantiation
├── components/
│   ├── UserTable.tsx          ← reads userService
│   ├── ProductList.tsx        ← reads productService
│   ├── LoginForm.tsx          ← reads authService
│   └── Header.tsx
└── App.tsx

What re-renders and what doesn't?

With useSignal — the component re-renders, not its parents

const name = signal("John")
const age = signal(25)

function NameDisplay() {
  const n = useSignal(name)    // re-renders only when name changes
  return <p>{n}</p>
}

function AgeDisplay() {
  const a = useSignal(age)     // re-renders only when age changes
  return <p>{a}</p>
}

function App() {
  // NEVER re-renders
  return (
    <div>
      <NameDisplay />
      <AgeDisplay />
      <button onClick={() => name.value = "Jane"}>Change name</button>
    </div>
  )
}

// Click "Change name":
//   App         → does NOT re-render
//   NameDisplay → re-renders (reads name)
//   AgeDisplay  → does NOT re-render (reads age, not name)

With <SignalValue> — nothing re-renders except the text

const count = signal(0)
const parity = computed(() => count.value % 2 === 0 ? 'Even' : 'Odd')

function Counter() {
  // This component NEVER re-renders
  return (
    <div>
      <p><SignalValue signal={count} /></p>     {/* only this text updates */}
      <p><SignalValue signal={parity} /></p>    {/* only this text updates */}
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

How to verify in the browser

  1. Install the React Developer Tools browser extension
  2. Open DevTools (F12) → go to the Profiler tab
  3. Click the gear icon → check "Highlight updates when components render"
  4. Interact with your app — components that re-render flash with a colored border

When do you still need useEffect?

rxor's effect() replaces most useEffect usage, but not all.

SituationUse effect() from rxorUse useEffect from React
React to data changesYesNo
Sync localStorage / document.titleYesNo
Log / analytics on changeYesNo
Load data when the app startsYes (runs immediately)No
Load data when a component mountsNoYes
Focus an input on mountNoYes
Set up a timer / intervalNoYes
Add event listeners on windowNoYes

In practice, rxor eliminates 80-90% of useEffect calls. The remaining ones are for DOM-specific lifecycle operations.


Imports

rxor has three entry points for tree-shaking:

// Core only (zero dependency, works without React)
import { signal, computed, effect, batch, untracked } from 'rxor/core'

// React hooks and components
import { useSignal, useComputed, SignalValue } from 'rxor/react'

// Store
import { createStore, useStore } from 'rxor/store'

// Or import everything from the root
import { signal, computed, useSignal, SignalValue, createStore } from 'rxor'

API reference

Core (rxor/core)

ExportDescription
signal(initial)Create a reactive signal
computed(fn)Create a derived computed value
effect(fn)Run a side effect that re-runs on dependency changes
batch(fn)Group updates into a single notification
untracked(fn)Read values without tracking

React (rxor/react)

ExportDescription
useSignal(signal)Subscribe to a signal, component re-renders on change
useComputed(fn)Create and subscribe to an inline computed
<SignalValue signal={sig} />Display a signal value without re-rendering the parent

Store (rxor/store)

ExportDescription
createStore(def)Group signals, computed, and actions
useStore(store, selector)Subscribe to a specific signal in a store

Types

interface Signal<T> {
  value: T
  peek(): T
  subscribe(cb: (value: T) => void): () => void
}

interface Computed<T> {
  readonly value: T
  peek(): T
  subscribe(cb: (value: T) => void): () => void
}

License

MIT