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.
npm install rxor
# or
pnpm add rxor
# or
yarn add rxorRequirements: React 18.0+ and TypeScript 5.0+
Compatibility: Works with any React UI library (Mantine, MUI, Chakra, Ant Design, etc.).
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.
rxor provides two approaches. Choose based on your needs.
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).
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.
// 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| Situation | Use |
|---|---|
| Display a text/number in JSX | <SignalValue> |
| Pass a value as prop to a component | useSignal |
| Use a value in a condition or loop | useSignal |
| Maximum performance, zero re-renders | <SignalValue> |
| Simple and quick | useSignal |
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"// 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 watchersWhen 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| Method | Description |
|---|---|
.value |
Read (with tracking) or write the value |
.peek() |
Read without creating a dependency |
.subscribe(cb) |
Listen for changes, returns an unsubscribe function |
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 automaticallyKey behaviors:
- Lazy — does not compute until
.valueis read - Cached — does not recompute if dependencies haven't changed
- Readonly — setting
.valuethrows 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!"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// 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)
})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
})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)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)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 effectSubscribe 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>
}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>
}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 },
})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>
)
}rxor does not include a service system in the package. You don't need one — a TypeScript class with signals is a service.
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.
// 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
}
}// service/index.ts
import { UserService } from './UserService'
import { AuthService } from './AuthService'
export const userService = new UserService()
export const authService = new AuthService()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/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>
)
}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
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)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>
)
}- Install the React Developer Tools browser extension
- Open DevTools (F12) → go to the Profiler tab
- Click the gear icon → check "Highlight updates when components render"
- Interact with your app — components that re-render flash with a colored border
rxor's effect() replaces most useEffect usage, but not all.
| Situation | Use effect() from rxor |
Use useEffect from React |
|---|---|---|
| React to data changes | Yes | No |
| Sync localStorage / document.title | Yes | No |
| Log / analytics on change | Yes | No |
| Load data when the app starts | Yes (runs immediately) | No |
| Load data when a component mounts | No | Yes |
| Focus an input on mount | No | Yes |
| Set up a timer / interval | No | Yes |
| Add event listeners on window | No | Yes |
In practice, rxor eliminates 80-90% of useEffect calls. The remaining ones are for DOM-specific lifecycle operations.
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'| Export | Description |
|---|---|
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 |
| Export | Description |
|---|---|
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 |
| Export | Description |
|---|---|
createStore(def) |
Group signals, computed, and actions |
useStore(store, selector) |
Subscribe to a specific signal in a store |
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
}MIT