popsicle 2.0.0
popsicle: ^2.0.0 copied to clipboard
A compact Flutter state-management package built around explicit dependencies, reactive values, Stores, effects, and composable async state.
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 runtime model stays equally small:
Intent / method
↓
Store
↙ ↘
State Effect
↓ ↓
.view() one-shot UI work
UI = f(state)
No code generation is required.
Why Popsicle #
- One declaration namespace:
Popsicle.* scope.get(...)for non-reactive accessscope.use(...)for reactive dependenciesReactiveValue<T>for tiny mutable stateStore<State>for structured feature state and behaviorIntentStore<State, Intent>when explicit intents improve a workflow- Dedicated one-shot effect channel separate from persistent state
.view()as the primary Flutter projection APIAsyncState<T>for loading/data/error without a special async Store typeAsync.combine2/3/4for multiple independent async sources.paramssemantics without exposing provider-family terminology- Container-scoped state for testing and isolation
Installation #
flutter pub add popsicle
import 'package:popsicle/popsicle.dart';
Requirements:
Dart >= 3.3.0 < 4.0.0
Flutter >= 3.19.0
Wrap the Flutter application once:
void main() {
runApp(
const Popsicle(
child: MyApp(),
),
);
}
1. Dependency injection — Popsicle.inject #
Use dependencies for API clients, repositories, storage, analytics, services, and application 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 react to future changes
scope.use(source)
access the current value
make this computation depend on future changes
Example of a reactive dependency:
final selectedUser = Popsicle.value(1);
final selectedUserLabel = Popsicle.inject(
(scope) => 'Selected user: ${scope.use(selectedUser)}',
);
When selectedUser changes, selectedUserLabel is recomputed.
2. Small state — Popsicle.value #
Use a ReactiveValue<T> when creating a full Store would be unnecessary.
final counter = Popsicle.value(0);
final themeMode = Popsicle.value(ThemeMode.system);
final selectedTab = Popsicle.value(0);
Render with .view() #
ReactiveValue.view works inside an ordinary StatelessWidget:
class CounterText extends StatelessWidget {
const CounterText({super.key});
@override
Widget build(BuildContext context) {
return counter.view(
(count) => Text('$count'),
);
}
}
This is the simplest expression of Popsicle's UI philosophy:
value.view(state => UI)
Mutate with 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 scoped. 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, or one-shot effects.
class CounterStore extends Store<int> {
CounterStore() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
Declare it with Popsicle.create:
final counter = Popsicle.create(
(_) => CounterStore(),
);
Render it directly:
counter.view(
(context, state, store) {
return FilledButton(
onPressed: store.increment,
child: Text('$state'),
);
},
);
The Store instance is supplied to the view, so UI calls normal Dart methods. There is no separate notifier lookup.
4. Persistent state vs one-shot effects #
Persistent state belongs in emit(...):
emit(nextState);
One-time work belongs in effect(...):
effect(ProfileSaved());
Effects are:
- not stored
- not replayed
- not used to rebuild UI
- delivered only to active effect listeners
Example:
sealed class CounterEffect {
const CounterEffect();
}
final class CounterReachedLimit extends CounterEffect {
const CounterReachedLimit(this.count);
final int count;
}
class CounterStore extends Store<int> {
CounterStore() : super(0);
void increment() {
final next = state + 1;
emit(next);
if (next == 5) {
effect(CounterReachedLimit(next));
}
}
}
UI:
counter.view(
(context, state, store) {
return Text('$state');
},
effect: (context, effect) {
if (effect case CounterReachedLimit(:final count)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Reached $count')),
);
}
},
);
5. Parameterized state — Popsicle.params #
Use Popsicle.params when a Store instance is identified by a runtime 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),
),
);
Usage:
user(42).view(
(context, state, store) {
return UserProfile(state: state);
},
);
Different arguments own independent graph identities/state:
user(42) != user(99)
For the uncommon case of parameterized non-state DI, Dependency.params(...) remains available as an advanced API.
6. Intent-driven workflows — IntentStore #
Normal Stores should expose normal methods. Use IntentStore only when an explicit intent boundary improves the feature.
sealed class CheckoutIntent {
const CheckoutIntent();
}
final class SubmitOrder extends CheckoutIntent {
const SubmitOrder();
}
class CheckoutStore extends IntentStore<CheckoutState, CheckoutIntent> {
CheckoutStore(this.repository) : super(const CheckoutState());
final CheckoutRepository repository;
@override
Future<void> onIntent(CheckoutIntent intent) async {
switch (intent) {
case SubmitOrder():
// perform workflow
break;
}
}
}
Declaration remains identical:
final checkout = Popsicle.create(
(scope) => CheckoutStore(
scope.get(checkoutRepository),
),
);
Dispatch:
checkout.view(
(context, state, store) {
return FilledButton(
onPressed: () => store.dispatch(const SubmitOrder()),
child: const Text('Submit'),
);
},
);
7. Async state without async Store types #
Async is an operation characteristic, not a different Store architecture.
class MessageStore extends Store<AsyncState<String>> {
MessageStore() : super(const AsyncState.idle());
Future<void> load() async {
final previous = state;
emit(AsyncState.loading(previous: previous));
try {
final message = await repository.loadMessage();
emit(AsyncState.data(message));
} catch (error, stackTrace) {
emit(
AsyncState.error(
error,
stackTrace,
previous: previous,
),
);
}
}
}
AsyncState<T> distinguishes:
idle
initial loading
value
refreshing with stale value
error
refresh error with stale value
Useful members include:
state.hasValue;
state.hasError;
state.isLoading;
state.isInitialLoading;
state.isRefreshing;
state.valueOrNull;
state.requireValue;
state.when(...);
state.map(...);
8. Combine independent async sources #
A Store can own multiple independent async resources:
class DashboardState {
const DashboardState({
this.profile = const AsyncState.idle(),
this.metrics = const AsyncState.idle(),
});
final AsyncState<Profile> profile;
final AsyncState<Metrics> metrics;
AsyncState<(Profile, Metrics)> get content {
return Async.combine2(profile, metrics);
}
}
Composition helpers:
Async.combine2(a, b);
Async.combine3(a, b, c);
Async.combine4(a, b, c, d);
Or:
final combined = profile.zip(metrics);
Combined state is derived rather than stored, so there is no third mutable value to synchronize.
9. PopsicleWidget and PopsicleBuilder #
.view() should cover most UI. When a widget needs several reactive sources together, use PopsicleWidget or PopsicleBuilder.
class AppHeader extends PopsicleWidget {
const AppHeader({super.key});
@override
Widget build(BuildContext context, Scope scope) {
final mode = scope.use(themeMode);
final user = scope.use(currentUser);
return Text('${user.name} • $mode');
}
}
scope.get(...) does not create a rebuild dependency:
final analytics = scope.get(analyticsService);
scope.use(...) does:
final theme = scope.use(themeMode);
For a local rebuild boundary:
PopsicleBuilder(
builder: (context, scope, child) {
final count = scope.use(counterValue);
return Text('$count');
},
)
10. Testing and Dart-only usage #
Create an isolated container:
final container = PopsicleContainer();
addTearDown(container.dispose);
Resolve dependencies/state:
final repository = container.get(userRepository);
final count = container.get(counterValue);
Mutate a reactive value:
container.set(counterValue, 10);
container.update(counterValue, (value) => value + 1);
Access a Store instance:
final store = container.store(counter);
store.increment();
expect(container.get(counter), 1);
Subscribe outside Flutter:
final subscription = container.subscribe(
counterValue,
(previous, next) {
print('$previous -> $next');
},
);
subscription.close();
11. Feature-first clean architecture #
Popsicle works naturally as the presentation/application state layer of a feature-first project:
features/
└── user_profile/
├── domain/
│ ├── entities/
│ ├── repositories/
│ └── usecases/
├── data/
│ ├── datasources/
│ ├── models/
│ └── repositories/
└── presentation/
├── state/
├── stores/
├── pages/
└── widgets/
A typical feature composition:
final remoteSource = Popsicle.inject(
(scope) => UserRemoteSource(scope.get(apiClient)),
);
final repository = Popsicle.inject<UserRepository>(
(scope) => UserRepositoryImpl(scope.get(remoteSource)),
);
final profile = Popsicle.params(
(scope, int userId) => UserProfileStore(
userId: userId,
repository: scope.get(repository),
),
);
Domain code stays plain Dart and does not depend on Popsicle.
Public API at a glance #
Recommended declarations #
Popsicle.inject(...)
Popsicle.value(...)
Popsicle.create(...)
Popsicle.params(...)
Dependency context #
scope.get(source)
scope.use(source)
Reactive state #
ReactiveValue<T>
Store<State>
IntentStore<State, Intent>
AsyncState<T>
State transitions #
emit(state)
effect(value)
dispatch(intent)
UI #
reactiveValue.view(...)
store.view(...)
PopsicleWidget
PopsicleBuilder
PopsicleConsumer // explicit lower-level Store widget
ReactiveBuilder // explicit lower-level ReactiveValue widget
Dart/testing #
PopsicleContainer
container.get(...)
container.store(...)
container.set(...)
container.update(...)
container.subscribe(...)
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 #
- UI is a function of state.
- Persistent state and one-shot effects are different channels.
- Dependencies are not state.
- Small state should stay small.
- Async work should not require another controller hierarchy.
- Derived state should be derived, not duplicated.
- Normal Dart methods are the default action API.
- IntentStore is optional structure, not mandatory ceremony.
- Framework vocabulary should describe intent, not implementation mechanics.
Author #
Maintained by AR Rahman GitHub: @ardevcraft
Crafted with ❤️ for open-source community. 🇧🇩
License #
Popsicle is distributed under the MIT license. See LICENSE and NOTICE for attribution and retained upstream notices.