pyreon

@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/rx
bun add @pyreon/rx
pnpm add @pyreon/rx
yarn add @pyreon/rx

Peer 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 changes
rx — composable reactive pipeline

Signal 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 involved

Collections

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)
rx — aggregation (sum / avg / max)

Aggregation

count

const total = rx.count(users) // number

sum

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.

Substring search across multiple keys:

const results = rx.search(users, searchQuery, ['name', 'email'])
// Filters users whose name or email contains the search string

Case-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 changes

Type-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'
Rx