@canlooks/reactive
v4.8.5
Published
A react tool for responding data and managing state.
Maintainers
Readme
@canlooks/reactive
A lightweight, fine-grained reactive state management framework for React. No providers, no context — just mutate data and components update automatically.
Features
- Auto-allocation: Properties, getters, and methods are automatically classified as reactive data, computed values, or batched actions
- Dependency-aware rendering: Components only re-render when the specific properties they reference change
- Fine-grained updates:
<Chip/>enables partial re-renders without touching the parent component - "No-provider" sharing: Reactive data created anywhere is automatically accessible — no
Provider, noinject - Deep reactivity: Optional recursive reactivity for nested objects with
reactive.deep() - Form bindings:
<Model/>anduseModel()provide two-way binding for form controls - Built-in persistence: Sync reactive state with
localStorage,sessionStorage, or custom async storage engines - TypeScript-first: Full type inference throughout
Installation
npm i @canlooks/reactiveQuick Example
Class Component
import {RC} from '@canlooks/reactive/react'
@RC
export default class Counter extends React.Component {
count = 1
unused = 2
onClick = () => {
this.count++ // Component updates — "count" is referenced in render()
}
noOp = () => {
this.unused++ // Component does NOT update — "unused" is never referenced in render()
}
render() {
return (
<div>
<div>{this.count}</div>
<button onClick={this.onClick}>Increase</button>
</div>
)
}
}Function Component
import {RC, useReactive} from '@canlooks/reactive/react'
const Counter = RC(() => {
const data = useReactive({
count: 1
})
return (
<div>
<div>{data.count}</div>
<button onClick={() => data.count++}>Increase</button>
</div>
)
})Core Concepts
Automatic Allocation
Every property in a reactive object or class instance is automatically classified:
import {reactive} from '@canlooks/reactive'
const data = reactive({
// Plain property → reactive (tracked for dependency)
count: 1,
// Getter → computed (memoized, re-evaluates only when dependencies change)
get double() {
return this.count * 2
},
// Method → action (batched — all mutations inside fire as one update)
increase() {
this.count++
}
})You can opt-out individual properties using the @ignore decorator.
Dependency Tracking
Components subscribe only to the properties they actually read during render. If data.count changes, only components that accessed data.count will re-render. Components that only accessed data.b are unaffected.
Batching with action() / act()
Multiple mutations outside an action trigger multiple updates. Wrap them to batch:
import {reactive, reactor, action, act} from '@canlooks/reactive'
const data = reactive({a: 1, b: 2})
reactor(() => [data.a, data.b], (next, prev) => {
console.log('changed!')
})
// Bad — effect fires twice
data.a++; data.b++
// Good — effect fires once
action(() => {
data.a++
data.b++
})()
// Equivalent IIFE form
act(() => {
data.a++
data.b++
})API Reference
@canlooks/reactive — Core
reactive(target) / reactive.deep(target)
Creates a reactive object or class. Accepts an object literal, a class, or used as a decorator.
import {reactive} from '@canlooks/reactive'
// Object
const data = reactive({a: 1, b: 2})
// Class (constructor)
const DataClass = reactive(class {
a = 1
static b = 2 // Static properties are also reactive
})
// Class (decorator)
@reactive
class Data {
a = 1
}reactive.deep(target) enables recursive reactivity — nested objects and arrays are also proxied.
const data = reactive.deep({
nested: {count: 0}
})
data.nested.count++ // Deeply trackedreactiveClass(target) / reactiveClass.deep(target)
Explicitly create a reactive class. Use when reactive() cannot distinguish between a class and a regular function.
import {reactiveClass} from '@canlooks/reactive'
const MyClass = reactiveClass(class {
value = 0
})reactiveObject(target) / reactiveObject.deep(target)
Explicitly create a reactive object without class-allocation logic.
import {reactiveObject} from '@canlooks/reactive'
const data = reactiveObject({count: 0})@ignore
Property decorator — excludes a property from auto-allocation on a reactive class.
import {reactive, ignore} from '@canlooks/reactive'
@reactive
class Data {
a = 1
@ignore
internal = Symbol() // Not reactive, not tracked
}reactor(refer, effect, options?)
Watches a derived value and fires effect when it changes. Returns a dispose function.
import {reactive, reactor, act} from '@canlooks/reactive'
const obj = reactive({a: 1, b: 2})
const dispose = reactor(() => obj.a, (newValue, oldValue) => {
console.log(`a changed from ${oldValue} to ${newValue}`)
})
act(() => obj.a++) // Logs: a changed from 1 to 2
act(() => obj.b++) // Nothing happens
dispose() // Stop watchingOptions:
| Option | Type | Description |
|--------|------|-------------|
| immediate | boolean | Run effect immediately on creation (default: false) |
| once | boolean | Dispose after first invocation (default: false) |
autorun(fn)
Automatically tracks all reactive properties accessed inside fn. Re-runs whenever any of them change. Returns a dispose function.
import {reactive, autorun, act} from '@canlooks/reactive'
const obj = reactive({a: 1})
const dispose = autorun(() => {
console.log('a is now:', obj.a)
})
act(() => obj.a++) // Logs: a is now: 2action(fn)
Wraps a function so that all reactive mutations inside it are batched into a single update cycle.
import {reactive, action} from '@canlooks/reactive'
const data = reactive({a: 1})
const increment = action(() => {
data.a++ // Batched
})
increment()act(fn)
IIFE (Immediately Invoked Function Expression) for action().
import {act} from '@canlooks/reactive'
act(() => {
// Mutations here are batched
})getOriginalObject(proxy)
Returns the raw (unproxied) object behind a reactive proxy.
import {reactive, getOriginalObject} from '@canlooks/reactive'
const data = reactive({a: 1})
const raw = getOriginalObject(data)
console.log(raw) // {a: 1}@canlooks/reactive/react — React Integration
RC / reactiveComponent(target)
The main entry point. Wraps a React component (class or function) to make it reactive. Automatically detects component type.
import {RC, useReactive} from '@canlooks/reactive/react'
// Function component
const Counter = RC(() => {
const data = useReactive({count: 1})
return <div onClick={() => data.count++}>{data.count}</div>
})
// Class component
@RC
class Counter extends React.Component {
count = 1
render() {
return <div onClick={() => this.count++}>{this.count}</div>
}
}RC.deep / reactiveComponent.deep enables deep reactivity on class component properties.
reactiveFC(target)
Explicitly wraps a function component. Use when you need manual type discrimination.
import {reactiveFC} from '@canlooks/reactive/react'
const MyComp = reactiveFC((props: {name: string}) => {
return <div>{props.name}</div>
})reactiveClassComponent(target) / reactiveClassComponent.deep(target)
Explicitly wraps a class component.
import {reactiveClassComponent} from '@canlooks/reactive/react'
const MyClassComp = reactiveClassComponent(class extends React.Component {
value = 0
render() {
return <div>{this.value}</div>
}
})React Hooks
useReactive(initialValue, options?)
Creates a stable reactive object that persists across re-renders. Accepts a value or a factory function.
import {RC, useReactive} from '@canlooks/reactive/react'
const Comp = RC(() => {
const data = useReactive({
count: 1,
name: 'world'
})
// Or lazy initialization
const lazy = useReactive(() => ({
count: expensiveComputation()
}))
return <div>{data.count}</div>
})useAutorun(fn)
Runs autorun scoped to the component's lifecycle (auto-disposes on unmount).
import {RC, useReactive, useAutorun} from '@canlooks/reactive/react'
const Comp = RC(() => {
const data = useReactive({count: 1})
useAutorun(() => {
console.log('count changed:', data.count)
})
return <div>{data.count}</div>
})useReactor(refer, effect, options?)
Runs reactor scoped to the component's lifecycle.
import {RC, useReactive, useReactor} from '@canlooks/reactive/react'
const Comp = RC(() => {
const data = useReactive({name: 'Alice'})
useReactor(() => data.name, (newName, oldName) => {
console.log(`Name changed: ${oldName} → ${newName}`)
})
return <input {...useModel(data.name)} />
})useAction(fn)
Wraps a callback in action() with a stable reference (like useCallback).
import {RC, useReactive, useAction} from '@canlooks/reactive/react'
const Comp = RC(() => {
const data = useReactive({a: 1, b: 2})
const increment = useAction(() => {
data.a++
data.b++ // Batched with data.a++
})
return <button onClick={increment}>Increment Both</button>
})useExternalReactive(refer)
Subscribes to an externally-defined reactive value — triggers re-render when it changes.
import {reactive, act} from '@canlooks/reactive'
import {RC, useExternalReactive} from '@canlooks/reactive/react'
const store = reactive({count: 0})
const Comp = RC(() => {
const count = useExternalReactive(() => store.count)
return <div>{count}</div>
})
// Anywhere in the app:
act(() => store.count++) // Comp re-rendersComponents
<Chip> / chip()
Fine-grained partial update. Anything inside <Chip> subscribes to its own reactive dependencies independently of the parent.
import {RC, useReactive, Chip} from '@canlooks/reactive/react'
const Index = RC(() => {
const data = useReactive({a: 1, b: 2})
return (
<div>
<Chip>
{/* Only re-renders when data.a changes */}
{() => <ChildA value={data.a} />}
</Chip>
<Chip>
{/* Only re-renders when data.b changes */}
{() => <ChildB value={data.b} />}
</Chip>
</div>
)
// Parent component Index never re-renders
})Function form:
import {chip} from '@canlooks/reactive/react'
// Equivalent to <Chip>{() => <Child />}</Chip>
return chip(() => <Child />)Chip variants:
| Component / Function | Description |
|----------------------|-------------|
| <Chip> / chip() | Wraps render in a reactive effect |
| <Chip.Strict> / strictChip() | Memoized variant — never re-renders from parent props |
| <AsyncChip> / asyncChip() | Defers rendering until after useEffect (avoids React mount warnings) |
| <AsyncChip.Strict> / asyncStrictChip() | Async + Strict combined |
<Model> / defineModel() / useModel()
Two-way binding for form controls. Keeps a reactive value synced with an <input>, <select>, or any controlled component.
useModel(initialValue) — Hook returning {value, onChange}:
import {RC, useModel} from '@canlooks/reactive/react'
const Form = RC(() => {
const name = useModel('Alice')
return (
<div>
<input {...name} />
<p>Current: {name.value}</p>
</div>
)
})<Model> — Wraps a child element with two-way binding:
import {RC, useReactive, Chip} from '@canlooks/reactive/react'
const Form = RC(() => {
const data = useReactive({name: 'Alice'})
return (
<Chip refer={() => data.name}>
<input />
</Chip>
)
})defineModel(Component, postValue?) — HOC for reusable model components:
import {defineModel} from '@canlooks/reactive/react'
const TextInput = defineModel(props => <input {...props} />)
// Usage
<TextInput refer={() => data.name} />Loading & Autoload
useLoading(fn, initialLoading?)
Tracks the loading state of an async function. Returns {load, loading, stacksCount}.
import {RC, useLoading} from '@canlooks/reactive/react'
const Comp = RC(() => {
const {load, loading} = useLoading(async (id: number) => {
const res = await fetch(`/api/user/${id}`)
return res.json()
})
return loading
? <div>Loading...</div>
: <button onClick={() => load(42)}>Fetch User</button>
})When initialLoading is a number, loading acts as a stack counter (increments for concurrent calls, decrements on return) instead of a boolean.
useAutoload(loadData, options?)
Defines a lazy-loading data object that loads on first access.
import {RC, useAutoload} from '@canlooks/reactive/react'
const Comp = RC(() => {
const user = useAutoload(async () => {
const res = await fetch('/api/user')
return res.json()
})
// Auto-loads on first access
return <div>{user.loading ? 'Loading...' : user.data?.name}</div>
})Decorators
@watch(refer, options?)
Class method decorator — syntactic sugar for reactor(). The decorated method runs whenever the referenced value changes.
import {watch} from '@canlooks/reactive'
import {RC} from '@canlooks/reactive/react'
@RC
class Index extends React.Component {
a = 1
@watch(ctx => ctx.a)
onAChanged(newValue: number, oldValue: number) {
console.log(`a: ${oldValue} → ${newValue}`)
}
@watch(() => externalStore.value)
onExternalChange(newVal: any) {
// Called when externalStore.value changes
}
}@loading(refer)
Class method decorator — toggles a boolean or increments a counter property during async method execution.
import {loading} from '@canlooks/reactive'
import {RC} from '@canlooks/reactive/react'
@RC
class Index extends React.Component {
busy = false
stack = 0
// Boolean loading: sets busy = true during execution, false afterwards
@loading(ctx => ctx.busy)
async fetchData() {
const res = await fetch('/api/data')
return res.json()
}
// Stack loading: increments stack during execution, decrements afterwards
@loading(ctx => ctx.stack)
async concurrentTask() {
// Multiple concurrent calls increase the stack
}
render() {
return this.busy ? <div>Loading...</div> : <div>Ready</div>
}
}Autoload / defineAutoload()
A pattern for data that auto-loads on first access and can be manually refreshed. Available from both the core and React packages.
import {reactive, Autoload} from '@canlooks/reactive'
@reactive
class UserData extends Autoload {
async loadData(id: number) {
const res = await fetch(`/api/user/${id}`)
return res.json()
}
}
const user = new UserData()
// Auto-loads on first access
console.log(user.data) // Triggers fetch
// Manually refresh with new params
await user.update(42)Or with the factory function:
import {defineAutoload} from '@canlooks/reactive'
const user = defineAutoload(async (id: number) => {
const res = await fetch(`/api/user/${id}`)
return res.json()
})Autoload API:
| Member | Description |
|---------------------|-----------------------------------------------------------|
| loading | boolean — whether data is currently loading |
| data | The loaded data (lazy — triggers loadData on first get) |
| setData(v) | Override the data value |
| load() | Trigger load (deduplicates concurrent calls) |
| update(...args) | Load with arguments, managing loading state |
| loadData(...args) | Abstract — implement your data-fetching logic here |
| onChange(v) | Optional callback after data is changed |
| onLoad() | Optional callback after data is loaded |
Storage — defineStorage() / registerStorageEngine()
Creates a reactive object that automatically persists to localStorage or sessionStorage.
import {defineStorage} from '@canlooks/reactive'
const user = defineStorage('user', {
name: 'canlooks',
age: 18
})
// Mutations sync to localStorage automatically
user.age++ // localStorage['user'] updated
user.name = 'Bob' // localStorage['user'] updatedOptions:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| mode | 'localStorage' \| 'sessionStorage' | 'localStorage' | Storage backend |
| async | boolean | true | Sync after nextTick instead of synchronously |
| debounce | number | — | Debounce writes by this many ms |
| deep | boolean | false | Deep reactivity |
For non-browser environments, register a custom storage engine:
import {registerStorageEngine, defineStorage} from '@canlooks/reactive'
registerStorageEngine({
setItem(key, value) { /* ... */ },
getItem(key) { /* ... */ },
removeItem(key) { /* ... */ }
})
const data = defineStorage('my-key', {value: 1})Async Storage — defineAsyncStorage() / registerAsyncStorageEngine()
Same as defineStorage(), but for async storage backends (e.g., React Native AsyncStorage, Electron storage).
import {registerAsyncStorageEngine, defineAsyncStorage} from '@canlooks/reactive'
import AsyncStorage from '@react-native-async-storage/async-storage'
registerAsyncStorageEngine(AsyncStorage)
const settings = await defineAsyncStorage('settings', {
theme: 'dark',
lang: 'en'
})
settings.theme = 'light' // Synced asynchronously@canlooks/reactive/forage — IndexedDB Persistence
Uses localforage for IndexedDB storage with optional fallback.
import {defineForage} from '@canlooks/reactive/forage'
const user = defineForage('user', {name: 'Alice', age: 30})
// Automatically loads from IndexedDB on first access
console.log(user.data) // {name: 'Alice', age: 30}
console.log(user.loading) // false
// Mutations are persisted to IndexedDB
user.data.name = 'Bob'
// Manual refresh
await user.update()Forage extends Autoload, so it supports all Autoload methods:
import {Forage} from '@canlooks/reactive/forage'
class UserStore extends Forage<User> {
constructor() {
super('user', {name: ''})
}
async loadData() {
// Custom load logic
const cached = await localforage.getItem(this.name)
return cached ?? {name: 'Guest'}
}
}External Data & Sharing State
No providers, no context — create reactive data anywhere and use it everywhere.
// shared/store.ts
import {reactive} from '@canlooks/reactive'
export const store = reactive({
user: {name: 'Alice'},
count: 0
})// ComponentA.tsx
import {RC} from '@canlooks/reactive/react'
import {store} from './store'
export const CompA = RC(() => {
// Only re-renders when store.count changes
return <div>{store.count}</div>
})// ComponentB.tsx
import {RC} from '@canlooks/reactive/react'
import {store} from './store'
export const CompB = RC(() => {
// Only re-renders when store.user.name changes
return <div>{store.user.name}</div>
})// anywhere.ts
import {act} from '@canlooks/reactive'
import {store} from './store'
act(() => store.count++) // CompA re-renders, CompB does NOTModule Structure
| Entry Point | Contents |
|-------------|----------|
| @canlooks/reactive | Core: reactive, reactor, autorun, action, act, ignore, watch, loading, Autoload, defineAutoload, defineStorage, defineAsyncStorage, registerStorageEngine, registerAsyncStorageEngine, reactiveClass, reactiveObject, getOriginalObject |
| @canlooks/reactive/react | React: RC, reactiveFC, reactiveClassComponent, useReactive, useAutorun, useReactor, useAction, useExternalReactive, Chip, Model, defineModel, useModel, useLoading, useAutoload |
| @canlooks/reactive/forage | Persistence: Forage, defineForage |
License
MIT
