Signals
react-logic's reactivity is built on signals: values that change over time and notify anything reading them. You read a signal by calling it and write to it by calling it with a value. The core building blocks live in @react-logic/state. The async helper lives in the optional @react-logic/utils:
| Function | What it does |
|---|---|
state(initial) | Holds a value you can read and write. s() reads, s(next) writes. |
computedState(fn) | A value derived from other signals. Recalculates only when its inputs change. |
asyncState(fn) | A value produced by an async function. Re-runs when the signals it reads change. |
effect(fn) | Runs side effects when signals change. Can return a cleanup function. |
batch(fn) | Groups several writes so anything reading them updates once at the end. |
state, computedState, effect, and batch ship in @react-logic/state (re-exported from @react-logic/react-logic). asyncState lives in the optional @react-logic/utils package.
state
const count = state(0);
count(); // 0 — read
count(5); // write
count(); // 5
state() returns a single function that's both getter and setter. Call it with no args to read; call it with one arg to write.
Put state() on a logic-class field, and useLogic re-renders the component whenever that signal changes. You don't need to subscribe. Reading a signal during render is enough.
computedState
class Cart {
items = state<Item[]>([]);
total = computedState(() => this.items().reduce((s, i) => s + i.price, 0));
}
computedState(fn) derives a value from other signals. It only re-runs when one of the signals it reads changes. Anything reading it only updates if the result actually changed. Chains of computeds are cheap.
Input variant
If the function takes an argument, the returned signal doubles as a setter. c(input) writes the input; c() reads the derived value:
class Search {
pattern = computedState((q = '') => new RegExp(q, 'i'));
}
const s = new Search();
s.pattern('foo'); // void — writes the input
s.pattern(); // RegExp(/foo/i) — reads the derived value
Use it when a derived value depends on a single piece of state nothing else needs to read. It saves you from declaring a separate state() field. The reactive state guide covers when the input is T vs T | undefined.
asyncState
Lives in the optional
@react-logic/utilspackage. Install withnpm install @react-logic/utils.
import { state } from '@react-logic/react-logic';
import { asyncState } from '@react-logic/utils';
class Search {
query = state('');
results = asyncState(async () => {
const q = this.query();
if (!q) return [];
return (await fetch(`/search?q=${q}`)).json();
});
}
asyncState re-runs its function whenever a signal it reads changes. Reading the result returns the latest resolved value, or undefined until the first resolve.
For cancellation and richer states (loading / error), see the async state guide.
Batching writes
When a method updates several signals in sequence, each write triggers an update. Usually that's fine. When intermediate states would be inconsistent, or when an effect would do expensive work on every step, wrap the writes in batch(fn):
import { batch, state } from '@react-logic/react-logic';
class Form {
name = state('');
email = state('');
age = state(0);
reset() {
batch(() => {
this.name('');
this.email('');
this.age(0);
});
// One update fires, with all three values reset.
}
}
startBatch() and endBatch() are also exported for cases where batch() can't wrap the code (for example, across async boundaries). See the batch operations guide.
Effects (when you need them)
Sometimes you want a side effect (logging, persisting, syncing) to run whenever signals change, with no value to expose. Use effect:
import { effect } from '@react-logic/react-logic';
class Logger {
count = state(0);
constructor() {
effect(() => {
console.log('count =', this.count());
});
}
}
Effects placed in a constructor are cleaned up automatically when the owning logic class or service is destroyed. The body can return a cleanup callback, like useEffect:
effect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
});
See the reactive state guide for tracking rules and anti-patterns.
What's not a signal
A few things on a logic class look reactive but aren't:
- Plain fields.
name = ''orcount = 0are just regular properties. Reassigning them won't re-render the component. Wrap the value instate()when you want it reactive. - Methods. A method is just a method. It can read and write signals inside; the method itself isn't tracked.
- DOM refs. react-logic doesn't manage refs. If you need direct DOM access, use a normal
useRefinside the React component.