blackbox

Reactive state management core for Dart.

Boxes hold state. Graphs wire dependencies. No code generation. No boilerplate.

See the full documentation with examples: README

Quick Start

import 'package:blackbox/blackbox.dart';

class CounterBox extends NoInputBox<int> {
  int _value = 0;

  @override
  int compute(int? previous) => _value;

  void inc() => action(() => _value++);
}

API

Box Types

Class Input Sync/Async
NoInputBox<O> none sync
Box<I, O> yes sync
NoInputAsyncBox<O> none async
AsyncBox<I, O> yes async

Graph

final graph = Graph.builder()
    .add(boxA)
    .add(boxB, input: (d) => d.whenReady(boxA))
    .build(start: true);

Effects

Graph.builder()
    .add(checkoutState)
    .addEffect<CheckoutState>(
      (d) => d.whenReady(checkoutState),
      run: (current, previous) {
        if (previous is! CheckoutSuccess && current is CheckoutSuccess) {
          cart.clear();
        }
      },
    )
    .build(start: true);

Effects are explicit graph sinks:

  • they run only when their input changes
  • they receive current and previous
  • async handlers are fire-and-forget

Persistence

Add a mixin to any box — implement persistKeyFor() and you're done:

// Sync box
class ThemeBox extends NoInputBox<String> with Persisted<void, String> {
  @override
  String persistKeyFor(void _) => 'theme';
  // ...
}

// Async box with persistence only
class UserBox extends AsyncBox<String, User>
    with AsyncPersisted<String, User> {
  @override
  String persistKeyFor(String id) => 'user:$id';
  // ...
}

// Async box with persistence + managed cache
class CachedUserBox extends AsyncBox<String, User>
    with AsyncPersisted<String, User>, AsyncManagedCache<String, User> {
  @override
  String persistKeyFor(String id) => 'user:$id';

  @override
  Duration get cacheTtl => Duration(minutes: 5);
  // ...
}

// Sync box with async fetch (always-available value, background refresh)
class StopListBox extends Box<String, StopList>
    with ManagedCache<String, StopList> {
  StopListBox(String input) : super(input, initialValue: StopList.empty);

  @override
  Duration get cacheTtl => Duration(seconds: 60);

  @override
  Future<StopList> fetch(String input) => api.getStopList(input);
}

Initialize persistence once before creating persistent boxes:

BlackboxPersistence.init(store, codecs: [UserJsonCodec()]);

Built-in codecs exist for int, double, String, and bool.

Mixin For Features
Persisted<I, O> Box, NoInputBox save/restore
AsyncPersisted<I, O> AsyncBox, NoInputAsyncBox save/restore
ManagedCache<I, O> on Box sync value + background fetch, TTL, fail-open, refresh(), invalidateCache()
AsyncManagedCache<I, O> on AsyncBox, NoInputAsyncBox TTL, stale-while-refresh, refresh(), invalidateCache() (works standalone or composed with AsyncPersisted)

Both cache mixins dedupe concurrent refresh() calls per box instance. Compose with Persisted / AsyncPersisted to persist cached values across restarts — the disk value wins over initialValue on boot.

In Flutter and Jaspr apps, prefer the platform adapters:

  • await SharedPrefsStore.preload() from blackbox_flutter
  • await LocalStorageStore.preload() from blackbox_jaspr

Lifecycle

  • resolveInitialValue(I input, O? initialValue) — seed the initial state before the first compute
  • onFirstCompute(I input, O? previous) — called once before the first compute
  • beforeCompute(I input, O? previous) — optionally short-circuit compute() with a ready Future
  • onReady() — called after initialization, before the first recompute
  • dispose() — called by graph.dispose()
  • action(() { ... }) — mutate state and trigger recomputation
  • await action(() async { ... }) — for async boxes, completes after the recompute finishes

License

MIT

Libraries

blackbox
Core Blackbox library for deterministic reactive computation.