@pyreon/rx
Signal-aware reactive transforms. Every function is overloaded — pass a Signal<T[]> and get a Computed<R> back (reactive), or pass a plain T[] and get R (static).
Not a lodash replacement — a signal operator library. Use es-toolkit for plain utility functions.
Installation
npm install @pyreon/rxbun add @pyreon/rxpnpm add @pyreon/rxyarn add @pyreon/rxPeer dependencies: @pyreon/reactivity
Quick Start
import { rx } from '@pyreon/rx'
import { signal } from '@pyreon/reactivity'
const users = signal<User[]>([])
// Signal in → Computed out (auto-reactive)
const active = rx.filter(users, u => u.active)
const sorted = rx.sortBy(active, 'name')
const top10 = rx.take(sorted, 10)
// top10() re-derives automatically when users changesSignal Overloading
Every function detects whether the input is callable (a signal/computed) or a plain value:
// Reactive — returns Computed<User[]>
const active = rx.filter(usersSignal, u => u.active)
active() // auto-tracks, re-derives on change
// Static — returns User[]
const active = rx.filter(usersArray, u => u.active)
// Just a plain filtered array, no signals involvedCollections
filter
const active = rx.filter(users, u => u.active)map
const names = rx.map(users, u => u.name)sortBy
Sort by a key string or comparator function:
const sorted = rx.sortBy(users, 'name')
const sorted = rx.sortBy(users, (a, b) => a.age - b.age)groupBy
const byRole = rx.groupBy(users, 'role')
// { admin: [...], user: [...] }keyBy
const byId = rx.keyBy(users, 'id')
// { "1": user1, "2": user2 }uniqBy
const unique = rx.uniqBy(users, 'email')take / skip / last
const first5 = rx.take(users, 5)
const rest = rx.skip(users, 5)
const last3 = rx.last(users, 3)chunk
const pages = rx.chunk(users, 10)
// [[...10], [...10], [...rest]]flatten
const flat = rx.flatten(nestedArrays)find
const admin = rx.find(users, u => u.role === 'admin')mapValues
Transform values of an object/record:
const counts = rx.mapValues(grouped, arr => arr.length)Aggregation
count
const total = rx.count(users) // numbersum
const totalAge = rx.sum(users, 'age')
const totalAge = rx.sum(users, u => u.age)min / max
const youngest = rx.min(users, 'age')
const oldest = rx.max(users, 'age')average
const avgAge = rx.average(users, 'age')Operators
distinct
Deduplicate a signal's emissions by value:
const unique = rx.distinct(statusSignal)scan
Accumulate values over time:
const total = rx.scan(priceSignal, (acc, price) => acc + price, 0)combine
Merge multiple signals into one:
const combined = rx.combine([nameSignal, ageSignal], ([name, age]) => `${name} (${age})`)Timing
debounce
Debounce a signal — the output updates only after the source stops changing for ms milliseconds:
const debouncedSearch = rx.debounce(searchQuery, 300)
// Use in JSX — only triggers API call after 300ms of quiet
effect(() => {
fetchResults(debouncedSearch())
})Returns a signal with a .dispose() method for manual cleanup.
throttle
Throttle a signal — the output updates at most once per ms milliseconds:
const throttledScroll = rx.throttle(scrollPosition, 100)Returns a signal with a .dispose() method for manual cleanup.
Search
Substring search across multiple keys:
const results = rx.search(users, searchQuery, ['name', 'email'])
// Filters users whose name or email contains the search stringCase-insensitive substring matching. No external dependencies.
Pipe
Chain transforms left-to-right. Returns a Computed when the source is a signal:
const topRisks = rx.pipe(
findings,
items => items.filter(f => f.severity === 'critical'),
items => items.sort((a, b) => b.score - a.score),
items => items.slice(0, 10),
)
// topRisks() — reactive, re-derives when findings changesType-safe up to 5 transforms in the chain.
Tree Shaking
All functions are also exported as named exports for tree-shaking:
import { filter, sortBy, take } from '@pyreon/rx'
// Same as rx.filter, rx.sortBy, rx.take
const result = take(sortBy(filter(users, u => u.active), 'name'), 10)TypeScript
import type { KeyOf, ReadableSignal } from '@pyreon/rx'