Signals, computed values, and effects for Java. A port of alien-signals 3.2.1, checked against upstream's own conformance suite.
Values recompute on demand, only when something reads them, and only when an input changed. It runs on one thread and needs no scheduler and no dependencies.
import static io.github.hbtweb.aliensignals.Signals.*;
Signal<Integer> count = signal(1);
Computed<Integer> doubled = computed(prev -> count.get() * 2);
effect(() -> System.out.println("count is " + count.get())); // prints 1
count.set(2); // prints 2
doubled.get(); // 4Not on Maven Central. Use JitPack, which builds this repo on demand:
<repositories>
<repository><id>jitpack.io</id><url>https://jitpack.io</url></repository>
</repositories>
<dependency>
<groupId>com.github.hbtweb</groupId>
<artifactId>alien-signals-java</artifactId>
<version>main-SNAPSHOT</version>
</dependency>Pin a tag or commit hash rather than main-SNAPSHOT for anything you care
about. Or build locally with mvn install, which gives you
io.github.hbtweb:alien-signals:3.2.1.
Requires Java 17+. No runtime dependencies.
signal(v) |
a value that changes over time. get() and set(v) |
computed(prev -> ...) |
a derived value. get() reads it. Cached |
effect(() -> ...) |
runs now, and again whenever what it read changes |
effectWithCleanup(() -> ... return cleanup) |
cleanup runs before each re-run and on disposal |
effectScope(() -> ...) |
groups effects so one stop() disposes them all |
batch(() -> ...) |
coalesce writes into a single notification |
untrack(() -> ...) |
read without subscribing |
effect, effectWithCleanup, and effectScope return an Effect. Call
stop() to dispose it. A computed's function receives its last value, which
helps when writing accumulators.
Effect e = effect(() -> { ... });
e.stop();
batch(() -> { a.set(1); b.set(2); }); // watchers run once, not twice
Effect scope = effectScope(() -> {
effect(() -> ...); // both effects belong to the scope
effect(() -> ...);
});
scope.stop(); // disposes bothA write that doesn't change the value propagates nothing. "Changed" means
Objects.equals by default. A collection or record can be equal without
being the same object. Such a write registers no change, and stops there.
That differs from the JavaScript original, which compares objects by reference and would re-run. It's the behaviour a Java caller expects, and it's what lets a recomputed result that still equals the old one stop a cascade. To get upstream's exact propagation, choose it per signal:
Signal<List<Integer>> s = signal(List.of(1, 2, 3), Signals.UPSTREAM);
Computed<int[]> c = computed(prev -> compute(), Arrays::equals);Signals.VALUE is the default. Signals.UPSTREAM compares numbers, strings,
and booleans by value and everything else by reference, matching the original
exactly.
Single-threaded, like the original. The graph, the active subscriber, and the flush queue all share static state. Calling from more than one thread is unsafe.
1226 tests. Every one of the 179 cases in
reactive-framework-test-suite,
the suite upstream runs itself. Then a differential fuzzer builds randomised
graphs and runs them against this port and the real upstream JavaScript,
comparing values and also how many times each node recomputed.
mvn test
VERIFICATION.md covers what those oracles catch and what they miss. bench/ holds Java Microbenchmark Harness (JMH) benchmarks. Read bench/PROVENANCE.md before trusting a number from them.
Tracks upstream npm. This is 3.2.1 because it ports alien-signals 3.2.1.
MIT. Original TypeScript © Johnson Chu. The Java port © hbtweb. See LICENSE.