ionex
Ionex is a lightweight Flutter state-management package built around a single atomic reactive primitive: Ion<T>. It keeps UI state small, explicit, predictable, and easy to compose without introducing a heavy framework.
Why Ionex?
- Minimal API: Zero friction, Fast, readable state creation and mutation.
- Predictable Flow: Built on Flutter's native
ValueNotifierfor predictable state flow. - No Magic Lifecycles: No complex background streams or hidden garbage collection issues.
- Hybrid Architecture: Works flawlessly as global atoms (Signals/Jotai style) or as scoped local business logic components via context-driven injection (Bloc/Provider style).
- Zero Dependencies: Keeps your app bundle lightweight and future-proof.
Highlights
Ion<T>for strongly-typed reactive state.IonBuilder<T>for targeted, lightweight widget subtree rebuilds.IonProvider<T extends Ion>for type-safe, scoped tree dependency injection.IonConsumer<T extends Ion<S>, S>for automatic context lookup and state listening.IonListener<T>for side effects without rebuilding (navigation, snackbars).IonLocatorfor lightweight synchronous Service Location (DI).MultiIonProviderto flatten provider structures and kill nesting boilerplate.- Fail-Fast Runtime Safety: Custom semantic errors (
IonexError) that diagnose misconfigurations (like missing providers or duplicate locator registrations) instantly in the console with actionable fixes. - 100% Widget and Unit test coverage verified.
- Example application included in
example/lib/main.dart
Current project status
- Version:
2.1.0 - Flutter tests: passing (100% coverage)
- Flutter analyzer: no issues found
- License: MIT
- Changelog:
CHANGELOG.md
Installation
Add ionex to your project:
flutter pub add ionex
Or add it directly to pubspec.yaml:
dependencies:
ionex: ^2.1.0
Then fetch dependencies:
flutter pub get
Requirements
- Flutter SDK >= 3.0.0
- Dart SDK >= 3.0.0 < 4.0.0
Package layout
lib/ionex.dart: public exportslib/locator.dart: service locator exportslib/src/core/ion.dart:Ion<T>reactive state primitivelib/src/locator/ion_locator.dart:IonLocatorservice locatorlib/src/widgets/ion_builder.dart: scoped UI rebuild widgetlib/src/widgets/ion_listener.dart: side-effect listener widgetlib/src/widgets/ion_provider.dart: scoped controller injectionlib/src/widgets/ion_consumer.dart: context-driven listener widgetlib/src/widgets/multi_ion_provider.dart: multi-provider compositionexample/lib/main.dart: runnable demonstration apptest/: unit and widget test coverage
Core API
Ion<T>
An Ion<T> is the fundamental reactive molecule of your application. It holds a typed state value and alerts listeners exclusively when its value changes.
// Instantiate with an initial value
final counter = Ion<int>(0);
print(counter.state); // Read synchronously: 0
counter.set(1); // Direct mutation
counter.update((current) => current + 1); // Derived mutation
counter.reset(); // Automatically snaps back to 0!
Key methods
set(T newValue): Immediately replaces the state and triggers updates if the new value differs.update(T Function(T currentState) updateFn): Mutates state using a callback function. Out-of-the-box support forces UI rebuilds even for in-place collection mutations (likeList.add).reset(): Automatically rolls the state back to the exact initial value defined in the constructor.state: Getter that synchronously exposes the current value.
IonLocator
A lightweight and synchronous Service Locator inspired by ASP.NET DI. It allows you to manage global dependencies or "Atoms" outside the widget tree.
import 'package:ionex/locator.dart';
// Register dependencies
IonLocator.addSingleton<MyService>(MyService());
IonLocator.addLazySingleton<MyController>(() => MyController());
IonLocator.addTransient<MyFactory>(() => MyFactory());
// Retrieve anywhere
final service = IonLocator.get<MyService>();
Note:
IonLocator.reset()is available for unit tests to clear all registrations between test runs.
Note:
Ion.reset()no longer accepts an arbitrary value. It always returns the controller to its original initial value.
UI Components & Dependency Injection
IonBuilder<T>
Listens to an external or global Ion atom and rebuilds only the builder's local widget tree layout.
IonBuilder<int>(
ion: counter,
builder: (context, value) {
return Text('Count: $value');
},
);
IonProvider<T extends Ion>
Injects a state controller into a local widget subtree using Flutter's native InheritedWidget mechanisms. It encapsulates business logic lifecycle, calling dispose() automatically when removed from the tree.
class AuthController extends Ion<String> {
AuthController() : super('unauthenticated');
void login(String user) => set('Welcome, $user');
}
// Ingesting into the tree
IonProvider<AuthController>(
create: (_) => AuthController(),
child: const ProfileScreen(),
);
You can read the controller anywhere below using IonProvider.of<T>(context).
IonProvider.of<T> also accepts a listen flag, so you can choose whether the caller rebuilds when the state changes:
// Fetch instance without listening (optimal for button action triggers)
final controller = IonProvider.of<AuthController>(context, listen: false);
controller.login('AtomicDev');
IonConsumer<T extends Ion<S>, S>
Combines dependency lookup and state listening into a single sleek component. It automatically resolves the requested controller from the context, listens to its state variations, and exposes both values directly in the builder function.
IonConsumer<AuthController, String>(
builder: (context, state, controller) {
return Column(
children: [
Text('Status: $state'),
ElevatedButton(
onPressed: () => controller.login('User123'),
child: const Text('Log In'),
),
],
);
},
);
IonListener<T>
Listens to an Ion<T> and triggers a callback when the state changes. Unlike IonBuilder, it does not rebuild its child. This is perfect for navigation, showing SnackBar, or opening dialogs.
IonListener<int>(
ion: counter,
listener: (context, state) {
if (state == 10) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Reached 10!')),
);
}
},
child: MyComplexWidget(),
);
MultiIonProvider
Annihilates the nested "Pyramid of Doom" widget tree when injecting multiple controllers at the same level.
MultiIonProvider(
providers: [
IonProvider<ThemeController>(create: (_) => ThemeController()),
IonProvider<LabMessageController>(create: (_) => LabMessageController('Init')),
IonProvider<LabStatusController>(create: (_) => LabStatusController(true)),
],
child: const HomeScreen(),
);
Runtime Safety & DX
Ionex is designed to fail fast during development rather than swallowing bugs or throwing generic runtime exceptions. It features a tailored hierarchy to guide you when things are misconfigured:
IonProviderNotFoundException: Thrown if you try to look up a controller viaBuildContextbut forgot to wrap the tree with anIonProvider. It automatically detects and prints the exact widget name that caused the failure.IonLocatorDependencyNotFoundException: Thrown if you callIonLocator.get<T>()before registering the dependency.IonLocatorDuplicateRegistrationException: Protects your memory state by crashing if you accidentally try to register the exact same type twice.
Breaking changes in 2.0.0
- Smart
Ion.reset(): No longer accepts an input argument. It implicitly rolls the state back to the originalinitialValuegiven during construction. - Simplified
IonProviderSignature: Constraints changed fromT extends Ion<dynamic>to simplyT extends Ion, improving syntactic clarity and IDE autocomplete engines. - Flexible
IonProvider(child): Thechildproperty is now optional inside theIonProviderconstructor to supportMultiIonProviderflattening. Standalone implementations are guarded by a runtime assertion requiring a non-nullchild.
Usage examples
A complete, working integration showcasing the power of IonLocator (Service Location) alongside Scoped Providers and Listeners:
import 'package:flutter/material.dart';
import 'package:ionex/ionex.dart';
import 'package:ionex/locator.dart';
// 1. Define Global Atoms/Services
class CounterController extends Ion<int> {
CounterController(super.value);
void increment() => update((c) => c + 1);
}
// 2. Define Scoped Controllers
class LabMessageController extends Ion<String> {
LabMessageController(super.value);
void changeMessage(String msg) => set(msg);
}
void main() {
// 3. Register Global Dependencies
IonLocator.addLazySingleton<CounterController>(() => CounterController(0));
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: MultiIonProvider(
providers: [
IonProvider<LabMessageController>(create: (_) => LabMessageController('Atomic Lab Active')),
],
child: const HomeScreen(),
),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final counter = IonLocator.get<CounterController>();
return IonListener<int>(
ion: counter,
listener: (context, count) {
if (count == 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(' Milestone Reached: 5!')),
);
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Ionex Molecular Lab')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IonConsumer<LabMessageController, String>(
builder: (context, message, controller) {
return Column(
children: [
Text(message),
ElevatedButton(
onPressed: () => controller.changeMessage('State mutated!'),
child: const Text('Mutate Message'),
),
],
);
},
),
const Divider(height: 48),
IonBuilder<int>(
ion: counter,
builder: (context, count) => Text('Global Count: $count', style: const TextStyle(fontSize: 32)),
),
ElevatedButton(
onPressed: () => counter.increment(),
child: const Text('Increment Global Atom'),
)
],
),
),
),
);
}
}
Example app
The example app in example/lib/main.dart demonstrates:
- theme switching with a global
Ion<ThemeMode> - context-scoped controllers with
IonProvider - reactive UI updates with
IonConsumerandIonBuilder - flattened provider composition using
MultiIonProvider
Run the example:
cd example
flutter run
Testing and verification
flutter test→ passedflutter analyze→ no issues found
The test suite validates:
Ioninitialization and state accessset()andupdate()workflowsreset()restoring original values- listener notification behavior
IonBuilderreactive rebuildsIonListenerside-effect triggeringIonLocatorsynchronous dependency resolutionIonProviderdisposal and context lookupIonConsumerimplicit controller resolutionMultiIonProviderprovider composition
Changelog
See CHANGELOG.md for full release notes.
License
Ionex is distributed under the MIT License. See LICENSE for the full text.
Contributing
Contributions are welcome. A good contribution generally includes:
- updating or adding tests for any behavior change
- updating documentation when the public API changes
- keeping examples aligned with the current package API
- verifying changes with
flutter testandflutter analyze