popsicle 2.1.1 copy "popsicle: ^2.1.1" to clipboard
popsicle: ^2.1.1 copied to clipboard

Compact Flutter state management with scoped DI, reactive values, Stores, effects, undo/redo history, streams, and composable async state.

Popsicle

Popsicle #

Small API. Explicit state. UI = f(state).

Popsicle is a compact Flutter state-management and dependency-injection package built around four declaration APIs:

Popsicle.inject(...); // dependencies
Popsicle.value(...);  // small reactive values
Popsicle.create(...); // structured Store state
Popsicle.params(...); // parameterized Store state

The core model stays intentionally small:

Intent / method / Stream
          ↓
        Store
       ↙     ↘
    State    Effect
      ↓        ↓
    .ui()   one-shot UI work

UI = f(state)

Popsicle 2.1 also adds opt-in undo/redo history without changing the normal Store model.

No code generation is required.

Highlights #

  • Popsicle.inject(...) for scoped dependencies
  • Popsicle.value(...) for lightweight reactive values
  • Popsicle.create(...) for structured state
  • Popsicle.params(...) for parameterized Store state
  • scope.get(...) for non-reactive access
  • scope.use(...) for reactive dependency access
  • Store<State> for feature state and behavior
  • IntentStore<State, Intent> for explicit intent-driven workflows
  • commit(...) for persistent state transitions
  • effect(...) for one-shot UI work
  • .ui() for UI = f(state) projection
  • History<State> for opt-in undo/redo
  • listenTo(...) for Store-owned stream subscriptions
  • AsyncState<T> and Async.combine2/3/4 for async composition
  • PopsicleContainer for Dart-only state, testing, and overrides

Installation #

flutter pub add popsicle
import 'package:popsicle/popsicle.dart';

Wrap the application once:

void main() {
  runApp(
    const Popsicle(
      child: MyApp(),
    ),
  );
}

1. Dependency injection — Popsicle.inject #

Use dependencies for API clients, repositories, storage, analytics, services, and configuration.

class ApiClient {
  const ApiClient(this.baseUrl);

  final String baseUrl;
}

final apiClient = Popsicle.inject(
  (_) => const ApiClient('https://api.example.com'),
);

Dependencies compose through Scope:

class UserRepository {
  const UserRepository(this.client);

  final ApiClient client;
}

final userRepository = Popsicle.inject(
  (scope) => UserRepository(
    scope.get(apiClient),
  ),
);

scope.get vs scope.use #

scope.get(source)
    access the current value
    do not create a reactive dependency

scope.use(source)
    access the current value
    react when that dependency changes

Example:

final selectedUser = Popsicle.value(1);

final selectedUserLabel = Popsicle.inject(
  (scope) => 'Selected user: ${scope.use(selectedUser)}',
);

2. Small state — Popsicle.value #

Use ReactiveValue<T> when a full Store would be unnecessary.

final counter = Popsicle.value(0);
final themeMode = Popsicle.value(ThemeMode.system);
final selectedTab = Popsicle.value(0);

Render it from an ordinary StatelessWidget:

class CounterText extends StatelessWidget {
  const CounterText({super.key});

  @override
  Widget build(BuildContext context) {
    return counter.ui(
      (count) => Text('$count'),
    );
  }
}

Mutate through Scope:

PopsicleBuilder(
  builder: (context, scope, child) {
    return FilledButton(
      onPressed: () {
        scope.update(counter, (value) => value + 1);
      },
      child: const Text('Increment'),
    );
  },
)

Replace directly:

scope.set(counter, 0);

ReactiveValue is container-scoped, so the same declaration can hold independent values in separate PopsicleContainers.


3. Structured state — Store #

Use a Store when state has behavior, multiple fields, async orchestration, stream input, history, or effects.

class CounterStore extends Store<int> {
  CounterStore() : super(0);

  void increment() => commit(state + 1);
  void decrement() => commit(state - 1);
  void reset() => commit(0);
}

Declare it:

final counter = Popsicle.create(
  (_) => CounterStore(),
);

Render it:

counter.ui(
  (context, state, store) {
    return FilledButton(
      onPressed: store.increment,
      child: Text('$state'),
    );
  },
);

Access inside PopsicleWidget #

class CounterActions extends PopsicleWidget {
  const CounterActions({super.key});

  @override
  Widget build(BuildContext context, Scope scope) {
    final count = scope.use(counter);     // reactive state
    final store = scope.store(counter);   // Store instance

    return Row(
      children: [
        Text('$count'),
        IconButton(
          onPressed: store.increment,
          icon: const Icon(Icons.add),
        ),
      ],
    );
  }
}

The distinction is deliberate:

scope.get(storeSource)   -> Store state, non-reactive
scope.use(storeSource)   -> Store state, reactive
scope.store(storeSource) -> Store instance / behavior

4. commit and effect #

Persistent state belongs in commit(...):

commit(nextState);

One-shot work belongs in effect(...):

effect(const ProfileSaved());

Consume effects through Store .ui():

profile.ui(
  (context, state, store) {
    return ProfileBody(state: state);
  },
  effect: (context, effect) {
    if (effect is ProfileSaved) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Saved')),
      );
    }
  },
);

Effects:

  • are delivered as occurrences
  • are not stored as State
  • are not replayed on rebuild
  • do not rebuild Store UI by themselves
  • are not part of undo/redo history

Use state for durable UI representation; use effects for snackbars, navigation, dialogs, analytics triggers, and similar one-shot work.


5. Explicit intents — IntentStore #

Normal methods are the simplest choice for most Stores. Use IntentStore<State, Intent> when explicit intents improve the feature model.

sealed class CounterIntent {
  const CounterIntent();
}

final class IncrementIntent extends CounterIntent {
  const IncrementIntent();
}

final class ResetIntent extends CounterIntent {
  const ResetIntent();
}

class CounterStore extends IntentStore<int, CounterIntent> {
  CounterStore() : super(0);

  @override
  FutureOr<void> onIntent(CounterIntent intent) {
    switch (intent) {
      case IncrementIntent():
        commit(state + 1);
        break;
      case ResetIntent():
        commit(0);
        break;
    }
  }
}

Dispatch:

store.dispatch(const IncrementIntent());

The model is:

Intent -> IntentStore -> commit(State) -> UI
                    `-> effect(...)   -> one-shot work

6. Undo / redo — History<State> #

History is opt-in. A normal Store has no history overhead.

class EditorStore extends Store<EditorState> with History<EditorState> {
  EditorStore() : super(const EditorState());

  void rename(String value) {
    commit(
      state.copyWith(name: value),
    );
  }

  @override
  int get historyLimit => 100;
}

Available API:

store.canUndo;
store.canRedo;
store.undoCount;
store.redoCount;

store.undo();
store.redo();
store.clearHistory();

undo() and redo() return bool to indicate whether a snapshot was restored.

History behavior:

commit(A -> B)
  A goes to undo history
  redo history is cleared

undo()
  current state goes to redo history
  previous snapshot becomes current state

redo()
  current state goes to undo history
  next snapshot becomes current state

Effects are never recorded or replayed.

Use immutable state with History<State>. Mutable objects can accidentally modify snapshots that are already in history.


7. Streams — listenTo #

Popsicle does not require a separate Stream Store type. A normal Store can own an external stream subscription and convert stream events into normal state transitions.

class MessageStore extends Store<AsyncState<List<Message>>> {
  MessageStore(Stream<List<Message>> messages)
      : super(const AsyncState.idle()) {
    listenTo(
      messages,
      onData: (items) {
        commit(
          AsyncState.data(items),
        );
      },
      onError: (error, stackTrace) {
        commit(
          AsyncState.error(
            error,
            stackTrace,
            previous: state,
          ),
        );
      },
    );
  }
}

listenTo(...):

  • is protected Store API
  • returns the StreamSubscription<T> for pause/resume/early cancellation
  • is automatically cancelled when the Store is disposed
  • supports onError, onDone, and cancelOnError
  • can call commit(...), effect(...), or normal Store methods from stream callbacks

For example, Firebase, WebSocket, database watch queries, connectivity, and sensor streams can all feed a normal Store:

External Stream
      ↓
   listenTo
      ↓
    Store
      ↓
   commit
      ↓
     .ui()

Subscription lifetime follows the Store lifetime, not an individual widget build.


8. Async state — AsyncState<T> #

AsyncState<T> is a normal immutable value, so one Store can own multiple independent async operations.

class ProfileState {
  const ProfileState({
    this.user = const AsyncState.idle(),
    this.posts = const AsyncState.idle(),
  });

  final AsyncState<User> user;
  final AsyncState<List<Post>> posts;
}

Loading with stale-data preservation:

final previous = state.user;

commit(
  state.copyWith(
    user: AsyncState.loading(previous: previous),
  ),
);

On success:

commit(
  state.copyWith(
    user: AsyncState.data(user),
  ),
);

On error:

commit(
  state.copyWith(
    user: AsyncState.error(
      error,
      stackTrace,
      previous: previous,
    ),
  ),
);

Compose required sources:

final content = Async.combine2(
  state.user,
  state.posts,
);

Also available:

Async.combine3(a, b, c);
Async.combine4(a, b, c, d);
a.zip(b);

9. Parameterized Stores — Popsicle.params #

Use parameterized Stores when one declaration needs independent state per argument.

class UserStore extends Store<UserState> {
  UserStore({
    required this.userId,
    required this.repository,
  }) : super(const UserState());

  final int userId;
  final UserRepository repository;
}

final user = Popsicle.params(
  (scope, int userId) => UserStore(
    userId: userId,
    repository: scope.get(userRepository),
  ),
);

Use it like a normal Store handle:

user(userId).ui(
  (context, state, store) {
    return UserProfile(
      state: state,
      onRefresh: store.refresh,
    );
  },
);

10. Selective rebuilds #

Inside PopsicleWidget, observe only a projection of Store state:

final unreadCount = scope.select(
  inbox,
  (state) => state.unreadCount,
);

The widget reacts to that selected value instead of the complete state object.


11. Explicit PopsicleConsumer #

.ui() is the recommended projection API. PopsicleConsumer remains available when an explicit widget is clearer.

PopsicleConsumer<CounterStore, int>(
  source: counter,
  build: (context, state, store) {
    return Text('$state');
  },
  effect: (context, effect) {
    // one-shot work
  },
)

12. Dart-only usage and testing #

PopsicleContainer owns an isolated graph outside Flutter widgets.

final container = PopsicleContainer();

final count = container.get(counter);
final store = container.store(counter);

store.increment();

container.dispose();

Overrides:

final container = PopsicleContainer(
  overrides: [
    apiClient.overrideWith(
      (_) => FakeApiClient(),
    ),
  ],
);

Subscribe outside Flutter:

final subscription = container.subscribe(
  counter,
  (previous, next) {
    // react to state
  },
);

Advanced compatibility/declaration types #

The package still exposes advanced declaration/handle types such as Dependency, Dependency.params, StoreHandle, StoreParams, and PopsicleOverride for testing, overrides, and migration. New application code should normally start with the Popsicle.* declarations above.


Design principles #

  1. UI is a function of state.
  2. Persistent state and one-shot effects are different channels.
  3. Dependencies are not state.
  4. Small state should stay small.
  5. Async work should not require another controller hierarchy.
  6. Derived state should be derived, not duplicated.
  7. Normal Dart methods are the default action API.
  8. IntentStore is optional structure, not mandatory ceremony.
  9. Framework vocabulary should describe intent, not implementation mechanics.

Author #

Maintained by AR Rahman GitHub: @ardevcraft

Crafted with ❤️ for open-source community. 🇧🇩

Stand With Palestine

License #

Popsicle is distributed under the MIT license. See LICENSE and NOTICE for attribution and retained upstream notices.

3
likes
145
points
160
downloads
screenshot

Documentation

Documentation
API reference

Publisher

verified publisherflutterwiki.com

Weekly Downloads

Compact Flutter state management with scoped DI, reactive values, Stores, effects, undo/redo history, streams, and composable async state.

Repository (GitHub)
View/report issues

Topics

#flutter #state-management #dependency-injection #reactive-programming #popsicle

License

MIT (license)

Dependencies

collection, flutter, meta, stack_trace, state_notifier

More

Packages that depend on popsicle