blackbox 0.10.4 copy "blackbox: ^0.10.4" to clipboard
blackbox: ^0.10.4 copied to clipboard

State management with one law: output = compute(input, state). Boxes with private memory, an explicit dependency graph you can render as a map, built-in persistence and caching. No code generation.

blackbox #

State management you can hold in your head. One law:

output = compute(input, state)

A box has one input, one output, and private memory. Three writers, never overlapping: the graph writes inputs, your buttons write memory, compute writes the output. The whole app is boxes and wires, drawn in one place. That's the entire model — everything below is it, applied.

No code generation. No build_runner. No BuildContext in your logic. Every word is plain Dart you can cmd-click into.

A box in 30 seconds #

final class CounterBox extends Box<int, int> {
  CounterBox.late() : super.late(initialValue: 0); // input arrives from the graph

  late final _count = state(0);                    // MEMORY — invisible outside

  @override
  int compute(int step) => _count.value;           // OUTPUT — the only writer

  void increment() => _count.value += input;       // BUTTON — writes memory
}

Want the count to survive a restart? Change one line:

late final _count = state(0, persist: 'counter'); // disk is not your problem

Per-user slots, with no cross-user leaks by construction:

late final _cart = state(<Item>[], persistFor: (userId) => 'cart:$userId');

Async: fetch means "go get a fresh one" #

When it runs is not your concern — the cache decides (first boot, TTL expiry, refresh(), an input change):

final class MenuBox extends NoInputCachedBox<Menu> {
  @override
  Cache<void, Menu> get cache =>
      const Cache(ttl: Duration(minutes: 5), persist: 'menu');

  @override
  Future<Menu> fetch() => api.fetchMenu();
}

Cold start is instant from disk; a stale value shows while a background re-fetch runs. UI consumes a typed AsyncOutput: loading / data / error, with the previous data still attached.

Many outputs: MultiBox (a real player) #

Status, position and track change at different rhythms — so they are separate observable cells, and a progress bar ticking 5×/second never rebuilds the track title:

final class PlayerBox extends MultiBox<String?> {      // input: one stream URL
  PlayerBox({required this.gateway});
  final PlayerGateway gateway;

  late final status = child(PlayerStatus.paused);      // OUTPUTS
  late final position = child(Duration.zero);
  late final track = child<TrackInfo?>(null);

  @override
  void compute(String? url) {                          // input changed:
    dispatch(track, null);                             //   old track isn't from this stream
    connect(gateway.onStatus, status);                 //   wire the streams
    connect(gateway.onPosition, position);             //   (auto-released on next input)
    gateway.setSource(url);
  }

  void toggle() => gateway.toggle();                   // BUTTON
}

Nobody outside can write a cell: child has no public setter, dispatch asserts ownership, and state cells are _private — the compiler guards the law, not your discipline.

The graph is the app, on one screen #

A wire is a dependency. Downstream simply doesn't run until upstream has output — loading states never leak into your wiring:

Graph<AppContext> buildApp(AppContext ctx) {
  final config  = ConfigBox(ctx.api);
  final billing = BillingBox(ctx.store);          // self-driven: no input needed
  final player  = PlayerBox(gateway: ctx.gateway);
  final phase   = AppPhaseBox.late();             // "what to show" as a pure formula

  return Graph.builder<AppContext>(context: ctx)
      .add(config)
      .addMultiBox(billing)
      .addMultiBox(player, input: (d) {
        final station = d.whenReady(config).selectedStation;
        final premium = d.whenReady(billing.isPremium);
        // Policy lives in the wire: a free user never streams premium —
        // by construction, not by discipline.
        return station.streamUrlFor(premium: premium);
      })
      .add(phase, input: (d) => (
        config: d.whenReady(config),
        premium: d.whenReady(billing.isPremium),
      ))
      .build(start: true);
}

The graph owns lifecycles end to end:

final app = buildApp(ctx)
  ..own(httpClient.close);   // created for the graph → dies with the graph

app.dispose();               // boxes, subscriptions, clients — everything

And it can show you the app:

await app.settled();         // test-friendly: wait until propagation is done
print(app.toMermaid());      // the dependency map as a rendered diagram

A dependency cycle doesn't freeze your app — the graph detects the storm, stops, and throws a diagnostic naming the trigger.

Who writes what (the entire framework, honestly) #

thing written by read by
input the graph compute
memory state() the box's buttons compute
output compute only UI, graph, effects
module child() its multibox (dispatch) UI, graph, effects

Violations fail to compile or assert loudly in debug. Eight public words: state, compute, fetch, child, dispatch, connect, own, .late. There is no second way to spell anything.

Runs anywhere — including headless #

The same graph runs in a background isolate (Android audio service, CLI, server): build it, skip the UI. Behavior differences are data, not code paths:

final graph = createApp(...);
graph.box<ForegroundBox>().set(false);   // no screen → skip artwork decoding

Bindings #

package what
blackbox_flutter BoxObserver (MobX-style read tracking), BoxProvider, SharedPreferences persistence
blackbox_jaspr the same for Jaspr web apps, localStorage persistence

Boxes themselves are pure Dart — the logic ports across frameworks (and away from blackbox: a cell maps 1:1 onto any store).

Coming from Riverpod #

Ask one question per provider: is this a truth, a fetch, or a formula? Truths become cells, fetches become cached boxes, formulas become compute.

Riverpod blackbox
Provider (derived value) a box's compute — derivation is the law itself
StateProvider / Notifier a box with state(...) cells and button methods
FutureProvider AsyncBox / NoInputAsyncBox
FutureProvider + caching hacks CachedBox + Cache(ttl:, persist:)
ref.watch(a) inside a provider a wire: input: (d) => d.whenReady(a) in the graph
ref.watch in a widget BoxObserver + reading the box (tracked automatically)
ref.read(x.notifier).foo() context.box<X>().foo() — buttons are plain methods
.family(arg) the box input; per-arg storage via persistFor: (arg) => ...
.select(...) unnecessary — MultiBox cells are separate observables by design
ref.listen (side effects) addEffect in the graph — all effects in one list
ref.onDispose graph.own(release)
ProviderScope(overrides:) BoxProvider.overrides
provider graph (implicit, discovered at runtime) the graph is explicit: one function, renderable via toMermaid()

The big difference is topological: Riverpod's dependency graph emerges from scattered ref.watch calls; here the graph is written down in one place and is the application.

Coming from MobX #

MobX blackbox
Store class a box
@observable field ask: user's truth → state(...) cell; fetched data → CachedBox
@computed compute — the only writer of output
@action a button method; multi-write batching via action(() {...})
reaction / autorun graph addEffect (app-level) or BoxObserver (UI)
ObservableList mutated in place immutable list replaced wholesale — distinct guards work, races don't
codegen + *.g.dart none — state(0) is executable Dart, cmd-click goes to real code
hand-rolled isLoading / disk cache in a store AsyncOutput already carries loading/error with previous data; Cache(persist:) is the disk

A faithful 1:1 port of a MobX store usually re-implements the library around the library (a load() with an in-flight flag is a hand-rolled CachedBox). Port the roles, not the lines: truth → cells, fetch → Cache, view state → its own tiny box.

Learn more #

The full docs ship inside this package (doc/ in the pub archive) — readable offline, greppable by tooling:

  • MODEL.md — the law, in full: three things, one formula, one writer per thing.
  • ARCHITECTURE.md — how to build a whole app: seven floors, distilled from a production radio app (native player, billing, alarms, background isolate).
  • MIGRATION.md — coming from 0.8/0.9: a script does the mechanical part.

Built and battle-tested on production apps first; every API here earned its place by deleting code in a real codebase.

2
likes
130
points
434
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

State management with one law: output = compute(input, state). Boxes with private memory, an explicit dependency graph you can render as a map, built-in persistence and caching. No code generation.

Repository (GitHub)
View/report issues

Topics

#reactive #state-management #data-flow #dependency-graph #computation-graph

License

MIT (license)

Dependencies

meta

More

Packages that depend on blackbox