Skip to content

Core Concepts

This page covers the fundamental building blocks of Reacton: the reactive graph, writable and read-only reactons, selectors, families, effects, and the store.

The Reactive Graph

Every reacton, computed value, and effect is a node in a directed acyclic graph (DAG). Edges represent dependencies -- "this node reads that node."

mermaid
graph TD
    A["counterReacton (writable)"] --> C["doubleCountReacton (computed)"]
    A --> D["isEvenReacton (computed)"]
    C --> W1["Widget A (subscriber)"]
    D --> W2["Widget B (subscriber)"]
    A --> E["logEffect (effect)"]

    style A fill:#4CAF50,color:#fff
    style C fill:#2196F3,color:#fff
    style D fill:#2196F3,color:#fff
    style W1 fill:#FF9800,color:#fff
    style W2 fill:#FF9800,color:#fff
    style E fill:#9C27B0,color:#fff

Each node has one of three states:

StateMeaning
CleanValue is current and valid
CheckA dependency might have changed; needs verification
DirtyValue is known to be stale; must recompute

When a writable reacton is set, the engine runs the two-phase mark/propagate algorithm:

  1. Mark -- The source is marked Dirty. All descendants are walked and marked Check.
  2. Propagate -- Nodes are processed in topological order (by depth level). Check nodes verify whether any source actually changed. If so, they recompute. If not, they are marked Clean without recomputation.

This guarantees glitch-free updates: no computed value or widget ever observes an inconsistent intermediate state.

reacton<T>() -- Writable State

The reacton() function creates a WritableReacton<T> -- the primary building block for mutable state.

dart
final counterReacton = reacton(0, name: 'counter');
final nameReacton = reacton('World', name: 'name');

Signature

dart
WritableReacton<T> reacton<T>(
  T initialValue, {
  String? name,
  ReactonOptions<T>? options,
  void Function(void Function<V>(WritableReacton<V>, V) set, T value)? onWrite,
})
ParameterTypeDescription
initialValueTThe starting value before any writes
nameString?Debug name for DevTools and logging
optionsReactonOptions<T>?Configuration for equality, middleware, persistence, etc.
onWriteFunction?Custom write handler that intercepts sets

Custom Write Handler

The onWrite callback lets you intercept writes and redirect them to other reactons:

dart
final celsiusReacton = reacton(0.0, name: 'celsius');

final fahrenheitReacton = reacton(
  32.0,
  name: 'fahrenheit',
  onWrite: (set, value) {
    // When fahrenheit is set, also update celsius
    set(celsiusReacton, (value - 32) * 5 / 9);
  },
);

ReactonOptions

ReactonOptions<T> configures advanced behavior:

dart
final counterReacton = reacton(
  0,
  name: 'counter',
  options: ReactonOptions<int>(
    keepAlive: true,          // Keep value even when no watchers remain
    debounce: Duration(ms: 300), // Debounce writes
    persistKey: 'counter',    // Auto-persist to storage
    serializer: intSerializer, // Serializer for persistence
    middleware: [loggingMiddleware], // Per-reacton middleware
    equals: (a, b) => a == b, // Custom equality
  ),
);
OptionTypeDefaultDescription
keepAliveboolfalseRetain value when no watchers exist
debounceDuration?nullDebounce duration for writes
persistKeyString?nullStorage key for auto-persistence
serializerSerializer<T>?nullSerialize/deserialize for persistence
middlewareList<Middleware<T>>[]Middleware chain for this reacton
equalsbool Function(T, T)?nullCustom equality (uses == by default)

computed<T>() -- Derived State

The computed() function creates a ReadonlyReacton<T> whose value is automatically derived from other reactons.

dart
final doubleCountReacton = computed(
  (read) => read(counterReacton) * 2,
  name: 'doubleCount',
);

final filteredTodosReacton = computed((read) {
  final todos = read(todosReacton);
  final filter = read(filterReacton);
  return todos.where((t) => t.matches(filter)).toList();
}, name: 'filteredTodos');

Signature

dart
ReadonlyReacton<T> computed<T>(
  T Function(ReactonReader read) compute, {
  String? name,
  ReactonOptions<T>? options,
})
ParameterTypeDescription
computeT Function(ReactonReader read)Function that derives the value. read accesses other reactons and tracks dependencies automatically.
nameString?Debug name
optionsReactonOptions<T>?Configuration options

Automatic Dependency Tracking

The read function passed to the compute callback does double duty:

  1. Returns the current value of the given reacton
  2. Registers it as a dependency in the reactive graph

Dependencies are re-tracked on every recomputation, so conditional reads work correctly:

dart
final displayReacton = computed((read) {
  final showDetail = read(showDetailReacton);
  if (showDetail) {
    // Only depends on detailReacton when showDetail is true
    return read(detailReacton);
  }
  return read(summaryReacton);
}, name: 'display');

TIP

Computed reactons are lazy. The compute function is not called until something first reads the value.

selector<T, S>() -- Sub-Value Selection

The selector() function creates a SelectorReacton<T, S> that watches a sub-value of another reacton. Only triggers updates when the selected portion changes.

dart
final userNameReacton = selector(
  userReacton,
  (user) => user.name,
  name: 'userName',
);

Signature

dart
SelectorReacton<T, S> selector<T, S>(
  ReactonBase<T> source,
  S Function(T) select, {
  String? name,
  ReactonOptions<S>? options,
})
ParameterTypeDescription
sourceReactonBase<T>The reacton to select from
selectS Function(T)Function to extract the sub-value
nameString?Debug name
optionsReactonOptions<S>?Configuration options

When to Use Selectors

Selectors are most useful when you have a complex state object but widgets only need a small piece of it:

dart
// Without selector: widget rebuilds on ANY user field change
final user = context.watch(userReacton);
Text(user.name); // Rebuilds even when user.email changes

// With selector: widget rebuilds ONLY when name changes
final name = context.watch(userNameReacton);
Text(name);

TIP

Under the hood, SelectorReacton is a subclass of ReadonlyReacton. It computes select(read(source)) and relies on the standard equality check to skip updates when the selected value has not changed.

family<T, Arg>() -- Parameterized Reactons

The family() function creates a ReactonFamily<T, Arg> -- a factory that produces a distinct reacton instance for each unique argument.

dart
final userReacton = family<AsyncValue<User>, int>((userId) {
  return asyncReacton(
    (read) => api.getUser(userId),
    name: 'user_$userId',
  );
});

// In a widget:
final user = context.watch(userReacton(42));

Signature

dart
ReactonFamily<T, Arg> family<T, Arg>(
  ReactonBase<T> Function(Arg arg) create,
)
ParameterTypeDescription
createReactonBase<T> Function(Arg)Factory function called once per unique argument

ReactonFamily API

Method / PropertyReturn TypeDescription
call(arg)ReactonBase<T>Get or create the reacton for the argument
contains(arg)boolCheck if a reacton exists for the argument
remove(arg)voidRemove the cached reacton
clear()voidRemove all cached reactons
keysIterable<Arg>All currently cached arguments
reactonsIterable<ReactonBase<T>>All currently cached reacton instances

Results are cached: calling family(42) twice returns the same reacton instance.

createEffect() -- Side Effects

The createEffect() function creates an EffectNode that runs side effects when its dependencies change.

dart
final dispose = store.registerEffect(
  createEffect((read) {
    final count = read(counterReacton);
    print('Counter changed to: $count');

    // Optionally return a cleanup function
    return () => print('Cleaning up');
  }, name: 'logCounter'),
);

// Later: dispose the effect
dispose();

Signature

dart
EffectNode createEffect(
  EffectCleanup? Function(ReactonReader read) fn, {
  String? name,
})
ParameterTypeDescription
fnEffectCleanup? Function(ReactonReader read)The effect function. Receives a read function for dependency tracking. May return a cleanup function.
nameString?Debug name

Effect Lifecycle

  1. The effect runs immediately when registered via store.registerEffect()
  2. Dependencies are automatically tracked through the read function
  3. When any dependency changes, the previous cleanup function (if any) is called, then the effect re-runs
  4. When disposed, the final cleanup function is called and the effect is unregistered from the graph

WARNING

Effects must be registered with a ReactonStore via store.registerEffect() to be active. The createEffect() function only creates the node -- it does not activate it.

ReactonStore

ReactonStore is the central value container for all reactons. It holds values, manages subscriptions, and bridges your code with the reactive graph engine.

dart
final store = ReactonStore();

// Read
final count = store.get(counterReacton);

// Write
store.set(counterReacton, 42);

// Functional update
store.update(counterReacton, (c) => c + 1);

// Subscribe
final unsubscribe = store.subscribe(counterReacton, (value) {
  print('Counter is now: $value');
});

// Batch multiple mutations
store.batch(() {
  store.set(firstNameReacton, 'John');
  store.set(lastNameReacton, 'Doe');
  // Computed values recompute only once after the batch
});

// Snapshot and restore
final snap = store.snapshot();
// ... make changes ...
store.restore(snap); // Roll back to the snapshot

// Dispose
store.dispose();

Constructor

dart
ReactonStore({
  StorageAdapter? storageAdapter,
  List<Middleware>? globalMiddleware,
})
ParameterTypeDescription
storageAdapterStorageAdapter?Adapter for reacton persistence
globalMiddlewareList<Middleware>?Middleware applied to all reactons

API Reference

MethodSignatureDescription
getT get<T>(ReactonBase<T> reacton)Read the current value (lazy-initializes if needed)
setvoid set<T>(WritableReacton<T> reacton, T value)Set a writable reacton's value
updatevoid update<T>(WritableReacton<T> r, T Function(T) updater)Functional update
subscribeUnsubscribe subscribe<T>(ReactonBase<T> r, void Function(T) listener)Subscribe to changes; returns an unsubscribe function
registerEffectUnsubscribe registerEffect(EffectNode effect)Register and activate an effect
batchvoid batch(void Function() fn)Execute mutations atomically
snapshotStoreSnapshot snapshot()Take an immutable snapshot of all values
restorevoid restore(StoreSnapshot snapshot)Restore state from a snapshot
forceSetvoid forceSet<T>(ReactonBase<T> r, T value)Set value without middleware (for testing)
removevoid remove(ReactonRef ref)Remove a reacton from the store
disposevoid dispose()Clean up all effects, listeners, and values
PropertyTypeDescription
graphReactiveGraphThe underlying reactive graph (for DevTools/testing)
storageAdapterStorageAdapter?The configured storage adapter
reactonRefsIterable<ReactonRef>All registered reacton refs
reactonCountintNumber of reactons in the store

ReactonRef and Identity

Every reacton has a unique ReactonRef that serves as its identity within the store. Refs are created automatically when you call reacton(), computed(), etc.

dart
class ReactonRef {
  final int id;            // Unique numeric identifier (auto-incremented)
  final String? debugName; // Optional name for DevTools
}

Two reacton declarations always produce distinct refs, even with identical initial values:

dart
final a = reacton(0, name: 'a');
final b = reacton(0, name: 'b');
assert(a.ref != b.ref); // Different identities

This is why reactons should be declared as top-level variables or static fields -- creating a new reacton inside build() would produce a new identity on every frame.

DANGER

Never create reactons inside a build() method. Each call to reacton() creates a new identity, so the store would treat it as a brand-new reacton on every rebuild.

Equality and When Updates Propagate

By default, Reacton uses Dart's == operator to compare values. A set() call that produces a value equal to the current value is a no-op -- no graph propagation occurs, no listeners fire, and no widgets rebuild.

dart
store.set(counterReacton, 0); // Initialize to 0
store.set(counterReacton, 0); // No-op: value unchanged

Custom Equality

Use ReactonOptions.equals to provide a custom comparison:

dart
final listReacton = reacton<List<int>>(
  [],
  name: 'list',
  options: ReactonOptions<List<int>>(
    equals: (a, b) => const ListEquality().equals(a, b),
  ),
);

For computed reactons, the same equality check applies. If a computed value recomputes to an equal result, no downstream propagation occurs. This is the mechanism that prevents unnecessary widget rebuilds.

What's Next

Released under the MIT License.